-
Notifications
You must be signed in to change notification settings - Fork 106
Add tenders finder to TinyFish-cookbook #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
📝 WalkthroughWalkthroughAdds two frontend apps (tenders-finder and tutors-finder) with a full Vite+React+TypeScript UI library, many Radix/ShadCN components, theme and Tailwind configs, utilities, and hooks. Adds Supabase client/types and multiple serverless functions: AI-powered link discovery and SSE proxying to Mino.ai. Introduces streaming multi-agent search hooks (useTenderSearch / useTutorSearch) that orchestrate per-link SSE requests, parse events, and incrementally update agent and result state. Also adds an in-memory toast system, tests, and build/tooling configurations. Sequence Diagram(s)sequenceDiagram
participant User
participant Frontend as Browser App
participant SupabaseFn as Supabase Function (mino-tender-search)
participant Mino as Mino API
User->>Frontend: Start search (sector + link)
Frontend->>SupabaseFn: POST { sector, url, agentId }
SupabaseFn->>Mino: POST /v1/automation/run-sse (url, goal) [X-API-Key]
Mino-->>SupabaseFn: stream SSE (data: {...})
SupabaseFn-->>Frontend: stream SSE events (text/event-stream)
Frontend->>Frontend: parse events -> update agent status, streamingUrl, tenders
Frontend-->User: render live previews and incremental results
sequenceDiagram
participant Frontend as Browser App
participant DiscoverFn as Supabase Function (discover-tender-links)
participant LovableAI as Lovable AI Gateway
Frontend->>DiscoverFn: POST { sector } (discover request)
DiscoverFn->>LovableAI: AI prompt -> request list of links
LovableAI-->>DiscoverFn: text response (may contain JSON list)
DiscoverFn->>DiscoverFn: extract/validate JSON or use fallback list
DiscoverFn-->>Frontend: JSON { links: [...] }
Frontend->>Frontend: merge/dedupe links -> start per-link search
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 1 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 12
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🤖 Fix all issues with AI agents
In `@tenders-finder/.env`:
- Around line 1-3: The committed .env contains leaked secrets
(VITE_SUPABASE_PROJECT_ID, VITE_SUPABASE_PUBLISHABLE_KEY, VITE_SUPABASE_URL);
remove these real values and replace them with placeholder values in the file,
add .env to .gitignore, and rotate/revoke the exposed publishable key in
Supabase immediately; ensure any deployment or CI uses secure secret storage
(environment variables or secret manager) and update documentation to show using
placeholders for VITE_SUPABASE_PROJECT_ID, VITE_SUPABASE_PUBLISHABLE_KEY, and
VITE_SUPABASE_URL.
In `@tenders-finder/src/components/ui/chart.tsx`:
- Around line 61-88: The ChartStyle component uses dangerouslySetInnerHTML with
unescaped id and config values, which allows CSS/HTML injection and fails for
special characters; replace the innerHTML approach by producing the stylesheet
as plain text child of the <style> element (i.e., render text content instead of
dangerouslySetInnerHTML), escape the selector id using CSS.escape (or a small
equivalent) when building selectors, and sanitize/validate color strings from
config before including them; update ChartStyle, references to id, THEMES and
colorConfig to use the escaped id and validated color values.
In `@tenders-finder/src/components/ui/form.tsx`:
- Around line 33-54: The useFormField function calls
getFieldState(fieldContext.name, formState) before validating the contexts and
uses a truthy check that never fails; move and strengthen the validation so you
verify both contexts before any access: at the top of useFormField check that
FormFieldContext and FormItemContext values are present and that
fieldContext.name exists (e.g., if fieldContext == null || !fieldContext.name)
and throw the existing error message if not; only after that call
getFieldState(fieldContext.name, formState) and then extract id from itemContext
— reference symbols: useFormField, FormFieldContext, FormItemContext,
useFormContext, getFieldState, formState.
In `@tenders-finder/src/components/ui/progress.tsx`:
- Around line 9-14: The Progress component currently destructures value (and may
receive max) but drops them when rendering ProgressPrimitive.Root, breaking ARIA
and correctness; forward both value and max into ProgressPrimitive.Root (e.g.,
include value={value} and max={max}) and update the indicator transform/width
calculation (currently using hard-coded "100 -") to compute percentage using
(value / max) * 100 so the rendered indicator and aria-valuenow reflect the
actual progress; ensure you still pass ...props for other attributes and use the
same symbols ProgressPrimitive.Root and the indicator element to locate where to
apply the changes.
In `@tenders-finder/src/components/ui/sonner.tsx`:
- Around line 1-4: The file uses React as a type namespace
(React.ComponentProps) but does not import React, causing type errors; add an
import for React (e.g., import * as React from "react") at the top of the module
so the type alias ToasterProps = React.ComponentProps<typeof Sonner> resolves
correctly and other JSX/type usages (ToasterProps, Sonner, toast) continue to
work as expected.
In `@tenders-finder/src/hooks/use-toast.ts`:
- Around line 169-177: The useEffect in use-toast currently depends on "state",
causing repeated registrations of setState into the listeners array; change the
effect dependency from [state] to [] so the listener is registered once on mount
and removed on unmount (ensure the block that pushes setState and the cleanup
that splices listeners by index remain as-is); reference symbols: useEffect,
setState, listeners, state.
In `@tenders-finder/src/integrations/supabase/client.ts`:
- Around line 5-11: Check that SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are
defined before calling createClient in this module (the constants SUPABASE_URL
and SUPABASE_PUBLISHABLE_KEY and the exported supabase from createClient). If
either is missing, throw a clear Error (or call a fail-fast routine) with a
descriptive message that includes which env var is absent so the app fails
immediately instead of passing undefined into createClient.
In `@tenders-finder/src/lib/api/mino.ts`:
- Around line 21-52: startTenderSearch currently calls
supabase.functions.invoke('mino-tender-search') with only { sector } and assumes
a JSON response, but the serverless function requires sector, url (and agentId)
and returns SSE; update startTenderSearch to either (A) iterate your
agent/source list and invoke the function per source passing { sector, url,
agentId } so the server validates and you handle each agent's response, or (B)
replace supabase.functions.invoke with a native fetch/EventSource-based request
to the function URL that sends { sector, url, agentId } and parse the
text/event-stream SSE responses, then call handlers.onAgentUpdate,
handlers.onTenderFound, and handlers.onAgentComplete as events arrive; locate
fixes around startTenderSearch, the supabase.functions.invoke call, and the code
paths that call handlers.onAgentUpdate/onTenderFound/onAgentComplete.
In `@tenders-finder/supabase/functions/discover-tender-links/index.ts`:
- Around line 3-11: The CORS preflight response in the serve handler is missing
Access-Control-Allow-Methods, causing browsers to reject cross-origin POSTs;
update the corsHeaders object (used in the OPTIONS branch where req.method ===
'OPTIONS') to include an 'Access-Control-Allow-Methods' entry listing the
allowed methods (e.g., 'GET, POST, OPTIONS') so the preflight response returns
the required methods header.
- Around line 35-61: Add an explicit timeout to the AI fetch call that requests
the Gemini model: wrap the fetch to
'https://ai.gateway.lovable.dev/v1/chat/completions' (the variable response)
with an AbortController (or use AbortSignal.timeout(10_000) if your Deno runtime
supports it) and pass the controller.signal in the fetch options; ensure the
timeout triggers controller.abort() (or relies on AbortSignal.timeout) after
~10_000ms and handle the abort/timeout error path so the function fails fast
instead of hanging.
In `@tenders-finder/supabase/functions/mino-tender-search/index.ts`:
- Around line 77-96: The fetch to 'https://mino.ai/v1/automation/run-sse' lacks
a timeout; wrap the request in an AbortController with a configurable timeout
(e.g., 10–30s), pass controller.signal to fetch, and clear the timeout once a
response is received; on timeout/AbortError, log including agentId and send an
ERROR SSE via controller.enqueue (using encoder) similar to the existing error
path, then controller.close() and return. Ensure you reference the same
variables used in the diff (apiKey, url, goal, agentId, controller, encoder) and
only abort the specific fetch call so other logic continues to work.
In `@tenders-finder/vite.config.ts`:
- Around line 8-14: The dev server is currently bound to all interfaces via
server.host set to "::", which exposes the Vite dev server beyond localhost;
update the configuration in vite.config.ts by changing server.host to
'localhost' (or make it configurable via an opt-in ENV like VITE_HOST) so the
default remains loopback-only; locate the server block (server: { host: "::",
port: 8080, hmr: { overlay: false } }) and replace the host value or add logic
to read an environment flag to only bind to all interfaces when explicitly
requested.
🟡 Minor comments (26)
tenders-finder/src/components/ui/chart.tsx-249-268 (1)
249-268:⚠️ Potential issue | 🟡 MinorUse stable, unique keys for legend entries.
key={item.value}can collide or be undefined.✅ Suggested fix
- {payload.map((item) => { + {payload.map((item, index) => { const key = `${nameKey || item.dataKey || "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); return ( <div - key={item.value} + key={`${item.dataKey ?? item.value ?? "legend"}-${index}`} className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")} > {itemConfig?.icon && !hideIcon ? ( <itemConfig.icon /> ) : ( <div className="h-2 w-2 shrink-0 rounded-[2px]" style={{ backgroundColor: item.color, }} /> )} {itemConfig?.label} </div> ); })}tenders-finder/src/components/ui/chart.tsx-163-214 (1)
163-214:⚠️ Potential issue | 🟡 MinorRender zero values and use stable list keys.
item.value &&skips0, andkey={item.dataKey}can be undefined or duplicate.✅ Suggested fixes
- {payload.map((item, index) => { + {payload.map((item, index) => { const key = `${nameKey || item.name || item.dataKey || "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); const indicatorColor = color || item.payload.fill || item.color; return ( <div - key={item.dataKey} + key={`${item.dataKey ?? key}-${index}`} className={cn( "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", indicator === "dot" && "items-center", )} > {formatter && item?.value !== undefined && item.name ? ( formatter(item.value, item.name, item, index, item.payload) ) : ( <> {itemConfig?.icon ? ( <itemConfig.icon /> ) : ( !hideIndicator && ( <div className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", { "h-2.5 w-2.5": indicator === "dot", "w-1": indicator === "line", "w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed", "my-0.5": nestLabel && indicator === "dashed", })} style={ { "--color-bg": indicatorColor, "--color-border": indicatorColor, } as React.CSSProperties } /> ) )} <div className={cn( "flex flex-1 justify-between leading-none", nestLabel ? "items-end" : "items-center", )} > <div className="grid gap-1.5"> {nestLabel ? tooltipLabel : null} <span className="text-muted-foreground">{itemConfig?.label || item.name}</span> </div> - {item.value && ( + {item.value !== undefined && item.value !== null && ( <span className="font-mono font-medium tabular-nums text-foreground"> {item.value.toLocaleString()} </span> )} </div> </> )} </div> ); })}tenders-finder/src/components/ui/sidebar.tsx-22-30 (1)
22-30:⚠️ Potential issue | 🟡 MinorType mismatch for
setOpensignature.The
setOpentype on line 25 declares(open: boolean) => void, but the actual implementation (lines 58-71) acceptsboolean | ((value: boolean) => boolean)to support the function updater pattern. This inconsistency could cause TypeScript errors if consumers attempt to use the updater pattern.🔧 Proposed fix
type SidebarContext = { state: "expanded" | "collapsed"; open: boolean; - setOpen: (open: boolean) => void; + setOpen: (open: boolean | ((value: boolean) => boolean)) => void; openMobile: boolean; setOpenMobile: (open: boolean) => void; isMobile: boolean; toggleSidebar: () => void; };tenders-finder/src/components/ui/sidebar.tsx-363-366 (1)
363-366:⚠️ Potential issue | 🟡 MinorTruncated CSS transition property.
Line 364 has
transition-[margin,opa]which appears truncated. It should betransition-[margin,opacity]for the opacity transition to work correctly during collapse animations.🐛 Proposed fix
className={cn( - "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", className, )}tenders-finder/src/components/ui/sidebar.tsx-534-537 (1)
534-537:⚠️ Potential issue | 🟡 MinorPotential SSR hydration mismatch with
Math.random().Using
Math.random()insideuseMemowith an empty dependency array causes hydration mismatches in SSR/SSG scenarios. The server generates one random value, while the client generates a different one, leading to React hydration warnings or visual flicker.🔧 Proposed fix using useId or state initialization
+import { useId } from "react"; + const SidebarMenuSkeleton = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & { showIcon?: boolean; } >(({ className, showIcon = false, ...props }, ref) => { - // Random width between 50 to 90%. - const width = React.useMemo(() => { - return `${Math.floor(Math.random() * 40) + 50}%`; - }, []); + // Generate consistent width using useId hash + const id = React.useId(); + const width = React.useMemo(() => { + // Simple hash from id to get deterministic value between 50-90% + const hash = id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + return `${(hash % 40) + 50}%`; + }, [id]);Alternatively, if the random appearance is essential and this component won't be SSR'd, you can initialize the width in a
useEffectwithuseState:const SidebarMenuSkeleton = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & { showIcon?: boolean; } >(({ className, showIcon = false, ...props }, ref) => { - // Random width between 50 to 90%. - const width = React.useMemo(() => { - return `${Math.floor(Math.random() * 40) + 50}%`; - }, []); + // Random width between 50 to 90%, initialized client-side only. + const [width, setWidth] = React.useState("70%"); + React.useEffect(() => { + setWidth(`${Math.floor(Math.random() * 40) + 50}%`); + }, []);tenders-finder/src/components/ui/button.tsx-39-43 (1)
39-43:⚠️ Potential issue | 🟡 MinorDefault
type="button"when rendering a native button.
Buttons without an explicit type default totype="submit"per the HTML spec, causing unintended form submissions when used inside forms. Extract thetypeprop and default to"button"for native buttons.🛠️ Proposed fix
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, variant, size, asChild = false, type, ...props }, ref) => { const Comp = asChild ? Slot : "button"; - return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />; + return ( + <Comp + type={asChild ? undefined : type ?? "button"} + className={cn(buttonVariants({ variant, size, className }))} + ref={ref} + {...props} + /> + ); }, );tenders-finder/src/components/ui/hover-card.tsx-13-23 (1)
13-23:⚠️ Potential issue | 🟡 MinorWrap
HoverCardContentin a Portal to prevent overflow clipping and match codebase patterns.All other overlay components in this codebase (Popover, DropdownMenu, ContextMenu, Select, Dialog, etc.) use Portal wrappers for their content. This aligns with Radix's documented approach for preventing content from being clipped by parent
overflow: hiddencontexts.Proposed fix
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + <HoverCardPrimitive.Portal> <HoverCardPrimitive.Content ref={ref} align={align} sideOffset={sideOffset} className={cn( "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className, )} {...props} /> + </HoverCardPrimitive.Portal> ));tenders-finder/src/components/ui/card.tsx-17-22 (1)
17-22:⚠️ Potential issue | 🟡 MinorFix ref element type mismatch for
CardTitle.
CardTitlerenders anh3element but the ref generic is typed asHTMLParagraphElement. This should beHTMLHeadingElementto match the actual rendered element and ensure correct type safety.🛠️ Proposed fix
-const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( +const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(tenders-finder/supabase/functions/mino-tender-search/index.ts-169-171 (1)
169-171:⚠️ Potential issue | 🟡 MinorSilent error swallowing may hide parsing issues.
Catching and ignoring JSON parsing errors without any indication could make debugging difficult when the Mino API returns unexpected data formats.
Suggested improvement
} catch (e) { - // Ignore parsing errors + // Log parsing errors for debugging but continue processing + console.warn(`[${agentId}] Failed to parse SSE data:`, line.slice(6)); }tenders-finder/src/components/ui/alert.tsx-29-34 (1)
29-34:⚠️ Potential issue | 🟡 MinorRef type mismatch: renders
<h5>but typed asHTMLParagraphElement.The component renders an
<h5>element but the ref is typed asHTMLParagraphElement. This should beHTMLHeadingElementfor type correctness.Suggested fix
-const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( +const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(tenders-finder/src/components/tender/CompareModal.tsx-90-103 (1)
90-103:⚠️ Potential issue | 🟡 MinorDead code:
officialTenderUrlfield check will never match.The condition
field.key === 'officialTenderUrl'will never be true becauseofficialTenderUrlis not included in theCOMPARE_FIELDSarray. This special-case rendering is unreachable. The official URL is already handled correctly in the dedicated row at lines 108-128.Suggested fix - simplify the cell rendering
{tenders.map((tender) => ( <td key={`${tender.id}-${field.key}`} className="p-3 text-foreground border border-border" > - {field.key === 'officialTenderUrl' ? ( - <a - href={tender[field.key]} - target="_blank" - rel="noopener noreferrer" - className="text-primary hover:underline inline-flex items-center gap-1" - > - View <ExternalLink className="w-3 h-3" /> - </a> - ) : ( - <span className="line-clamp-3"> - {tender[field.key] || 'N/A'} - </span> - )} + <span className="line-clamp-3"> + {tender[field.key] || 'N/A'} + </span> </td> ))}tenders-finder/src/components/ui/alert.tsx-36-41 (1)
36-41:⚠️ Potential issue | 🟡 MinorRef type mismatch: renders
<div>but typed asHTMLParagraphElement.The component renders a
<div>element but the ref is typed asHTMLParagraphElement. This should beHTMLDivElementfor type correctness.Suggested fix
-const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>( +const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(tenders-finder/src/components/tender/CompareModal.tsx-27-31 (1)
27-31:⚠️ Potential issue | 🟡 MinorExit animations won't trigger due to early return.
The early
return nullon line 28 preventsAnimatePresencefrom detecting the exit and running theexitanimations defined on the motion elements. WhenisOpenbecomes false, the component immediately returnsnullwithout giving AnimatePresence a chance to animate out.Suggested fix
export function CompareModal({ isOpen, onClose, tenders }: CompareModalProps) { - if (!isOpen) return null; - return ( <AnimatePresence> - <motion.div + {isOpen && ( + <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} // ... rest of component - </motion.div> + </motion.div> + )} </AnimatePresence> ); }tenders-finder/src/components/ui/breadcrumb.tsx-69-80 (1)
69-80:⚠️ Potential issue | 🟡 MinorFix displayName typo for BreadcrumbEllipsis
The displayName string is misspelled, which makes DevTools inspection harder.
🛠️ Suggested fix
-BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"; +BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";tenders-finder/src/components/ui/carousel.tsx-70-81 (1)
70-81:⚠️ Potential issue | 🟡 MinorHandle arrow keys for vertical orientation
ArrowUp/ArrowDown should control vertical carousels; current logic only reacts to left/right.
🛠️ Suggested update
- const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent<HTMLDivElement>) => { - if (event.key === "ArrowLeft") { - event.preventDefault(); - scrollPrev(); - } else if (event.key === "ArrowRight") { - event.preventDefault(); - scrollNext(); - } - }, - [scrollPrev, scrollNext], - ); + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + const isHorizontal = orientation === "horizontal"; + if ( + (isHorizontal && event.key === "ArrowLeft") || + (!isHorizontal && event.key === "ArrowUp") + ) { + event.preventDefault(); + scrollPrev(); + } else if ( + (isHorizontal && event.key === "ArrowRight") || + (!isHorizontal && event.key === "ArrowDown") + ) { + event.preventDefault(); + scrollNext(); + } + }, + [orientation, scrollPrev, scrollNext], + );tenders-finder/src/components/ui/command.tsx-70-74 (1)
70-74:⚠️ Potential issue | 🟡 MinorPreserve base styles in CommandEmpty
Props are spread after
className, so consumer className can wipe default styles. Merge withcn.🛠️ Suggested fix
-const CommandEmpty = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Empty>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> ->((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />); +const CommandEmpty = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Empty>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> +>(({ className, ...props }, ref) => ( + <CommandPrimitive.Empty + ref={ref} + className={cn("py-6 text-center text-sm", className)} + {...props} + /> +));tenders-finder/src/components/ui/carousel.tsx-91-102 (1)
91-102:⚠️ Potential issue | 🟡 MinorRemove both event listener registrations on cleanup
The effect registers both
reInitandselectlisteners, but cleanup only removesselect. According to Embla's API, listeners persist afterreInit()and must be explicitly removed or they'll accumulate on subsequent effect runs.Suggested fix
return () => { api?.off("select", onSelect); + api?.off("reInit", onSelect); };Note: This same pattern appears in
anime-watch-hub/components/ui/carousel.tsxandsummer-school-finder/src/components/ui/carousel.tsx.tenders-finder/src/hooks/useTenderSearch.ts-95-107 (1)
95-107:⚠️ Potential issue | 🟡 MinorExplicitly handle null response body to avoid agent getting stuck in "connecting" state
If
response.bodyis null (which can occur even withresponse.ok === truein certain HTTP scenarios), the entire streaming processing block is skipped and the agent never leaves the "connecting" status. This should be treated as an error condition.🛠️ Suggested handling
- const reader = response.body?.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - if (reader) { - while (true) { + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('Streaming response body is unavailable'); + } + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { const { done, value } = await reader.read(); if (done) break; @@ - } - } + }The error will be caught by the existing error handler at line 216 and properly set the agent status to 'error'.
tenders-finder/index.html-6-19 (1)
6-19:⚠️ Potential issue | 🟡 MinorReplace placeholder metadata before release.
Line 6-19 still contain TODOs and placeholder branding (title, description, OG/Twitter meta, and image). These should be updated to the actual app name and assets to avoid incorrect previews/SEO.
tenders-finder/src/components/ui/menubar.tsx-185-189 (1)
185-189:⚠️ Potential issue | 🟡 MinorFix
displayNamecasing for MenubarShortcut.Line 188 uses
displayname(lowercasen), but React expectsdisplayName. All other components in this file correctly usedisplayName.🛠️ Suggested fix
-MenubarShortcut.displayname = "MenubarShortcut"; +MenubarShortcut.displayName = "MenubarShortcut";tenders-finder/README.md-1-39 (1)
1-39:⚠️ Potential issue | 🟡 MinorResolve markdownlint warnings (duplicate heading, bare URLs, bold-as-heading).
Consider converting bold section labels to headings and using Markdown links for URLs to clear MD034/MD024/MD036.✅ Example formatting fixes
-**Live Link**: https://tender-scout-singapore.lovable.app +## Live Link +[Tender Scout Singapore](https://tender-scout-singapore.lovable.app) -## What This Project Is - +## What This Project Is -**AI-based Link Discovery** +### AI-based Link DiscoveryAlso applies to: 84-89
tenders-finder/README.md-6-27 (1)
6-27:⚠️ Potential issue | 🟡 MinorFix the project description — it describes summer schools, not tenders.
This section should describe the Government Tender Finder for Singapore rather than a summer school tool.✏️ Example edit
-This project is an AI-powered summer school discovery and comparison tool that automatically finds, scans, and extracts information from official summer school websites worldwide. +This project is an AI-powered government tender discovery and comparison tool that finds, scans, and extracts tenders from official Singapore procurement portals.tenders-finder/src/components/tender/AgentPreviewGrid.tsx-22-31 (1)
22-31:⚠️ Potential issue | 🟡 MinorSorting bug: priority
0gets overwritten by||.
Use??so "searching" stays at the highest priority.✅ Fix priority fallback
- return (priority[a.status] || 5) - (priority[b.status] || 5); + return (priority[a.status] ?? 5) - (priority[b.status] ?? 5);tenders-finder/src/hooks/use-toast.ts-140-144 (1)
140-144:⚠️ Potential issue | 🟡 MinorType mismatch in
updatefunction signature.The
updatefunction acceptsToasterToastbut should acceptPartial<ToasterToast>to match theUPDATE_TOASTaction type defined on lines 37-39:🐛 Proposed fix
- const update = (props: ToasterToast) => + const update = (props: Partial<ToasterToast>) => dispatch({ type: "UPDATE_TOAST", toast: { ...props, id }, });tenders-finder/src/components/tender/AgentPreviewCard.tsx-210-215 (1)
210-215:⚠️ Potential issue | 🟡 MinorPotential runtime error if
agent.urlis malformed.
new URL(agent.url).hostnamewill throw aTypeErrorifagent.urlis empty, malformed, or not a valid URL, crashing the component.🛡️ Proposed defensive fix
{/* Footer */} <div className="px-3 py-2 bg-gradient-to-r from-muted/30 to-muted/20 border-t border-border"> <p className="text-xs text-muted-foreground truncate flex items-center gap-1.5"> <Globe className="w-3 h-3 flex-shrink-0" /> - {new URL(agent.url).hostname} + {(() => { + try { + return new URL(agent.url).hostname; + } catch { + return agent.url; + } + })()} </p> </div>Or extract to a helper function for cleaner code.
tenders-finder/src/components/tender/LiveBrowserModal.tsx-22-29 (1)
22-29:⚠️ Potential issue | 🟡 MinorExit animations won't trigger due to early return.
The early return
if (!isOpen) return nullon line 22 bypassesAnimatePresence, so the exit animations defined on lines 29 and 36 will never run. The modal will disappear instantly instead of animating out.Move the conditional inside
AnimatePresence:🐛 Proposed fix
- if (!isOpen) return null; - return ( <AnimatePresence> - <motion.div + {isOpen && ( + <motion.div - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - exit={{ opacity: 0 }} + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + ... + </motion.div> + )} </AnimatePresence> );
🧹 Nitpick comments (25)
tenders-finder/src/components/ui/navigation-menu.tsx (1)
47-47: Nitpick: Redundant "group" class.The
"group"class is already included innavigationMenuTriggerStyle(line 38), so adding it again here is unnecessary.♻️ Suggested fix
- className={cn(navigationMenuTriggerStyle(), "group", className)} + className={cn(navigationMenuTriggerStyle(), className)}tenders-finder/src/components/ui/sheet.tsx (1)
40-41: Minor: Extra whitespace in class string.There's a double space between
w-3/4andborder-l.🧹 Suggested fix
right: - "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",tenders-finder/src/components/ui/input-otp.tsx (1)
27-29: Consider adding defensive access for the slot context.The destructuring of
inputOTPContext.slots[index]assumes the context is always properly initialized and the index is within bounds. While this follows standard ShadCN patterns (assuming correct usage within theOTPInputhierarchy), adding a defensive check could prevent cryptic errors if misused.🛡️ Optional defensive check
>(({ index, className, ...props }, ref) => { const inputOTPContext = React.useContext(OTPInputContext); - const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; + const slot = inputOTPContext?.slots?.[index]; + const { char, hasFakeCaret, isActive } = slot ?? {};tenders-finder/src/components/ui/calendar.tsx (1)
10-51: Component implementation follows ShadCN UI patterns well.The classNames structure correctly uses react-day-picker v8's snake_case naming convention, and spreading
...classNamesat the end allows consumers to override defaults. The responsive layout withsm:breakpoints is appropriate.One minor note on lines 45-46: the
_propsparameter is collected but unused. If the props from DayPicker aren't needed, this could be simplified.♻️ Optional: Simplify unused props
components={{ - IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />, - IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />, + IconLeft: () => <ChevronLeft className="h-4 w-4" />, + IconRight: () => <ChevronRight className="h-4 w-4" />, }}tenders-finder/src/components/ui/sidebar.tsx (1)
67-68: Consider addingSameSiteattribute to cookie.The cookie is set without an explicit
SameSiteattribute. While modern browsers default toLax, being explicit improves clarity and ensures consistent behavior across all browsers.♻️ Proposed fix
- document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}; SameSite=Lax`;tenders-finder/src/components/ui/progress.tsx (1)
15-18: Usemaxand clampvalueto avoid overflow.The indicator math is hard-coded to 100, so custom
maxprops or out-of-range values will misrender. Consider clamping and scaling bymax.♻️ Proposed refactor
-const Progress = React.forwardRef< +const Progress = React.forwardRef< React.ElementRef<typeof ProgressPrimitive.Root>, React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> ->(({ className, value, ...props }, ref) => ( +>(({ className, value, max = 100, ...props }, ref) => ( <ProgressPrimitive.Root ref={ref} className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)} {...props} > <ProgressPrimitive.Indicator className="h-full w-full flex-1 bg-primary transition-all" - style={{ transform: `translateX(-${100 - (value || 0)}%)` }} + style={{ + transform: `translateX(-${ + 100 - ((Math.min(Math.max(value ?? 0, 0), max) / max) * 100) + }%)`, + }} /> </ProgressPrimitive.Root> ));tenders-finder/.gitignore (1)
1-24: Consider adding.env*patterns to prevent accidental secret commits.Given that this project integrates with Supabase (per the PR summary), environment files containing API keys and secrets are likely in use. Adding patterns like
.env,.env.local,.env*.localwould provide defense-in-depth against accidental secret exposure.Suggested addition
node_modules dist dist-ssr *.local + +# Environment files +.env +.env.* +!.env.exampletenders-finder/src/hooks/use-mobile.tsx (1)
8-16: Consider usingmql.matchesin the change handler for consistency.The
onChangehandler recalculates usingwindow.innerWidthinstead of usingmql.matches. While functionally equivalent, usingmql.matchesis more consistent with the media query approach and avoids potential edge-case discrepancies.Suggested refinement
React.useEffect(() => { const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + setIsMobile(mql.matches); }; mql.addEventListener("change", onChange); - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + setIsMobile(mql.matches); return () => mql.removeEventListener("change", onChange); }, []);tenders-finder/supabase/functions/discover-tender-links/index.ts (1)
20-32: Deduplicate the default links list to prevent drift.
The same list appears in three branches; centralizing it reduces maintenance risk.♻️ Suggested refactor
const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", }; + +const DEFAULT_LINKS = [ + { url: "https://www.gebiz.gov.sg/", name: "GeBIZ" }, + { url: "https://www.tendersontime.com/singapore-tenders/", name: "Tenders On Time" }, + { url: "https://www.biddetail.com/singapore-tenders", name: "Bid Detail" }, + { url: "https://www.tendersinfo.com/global-singapore-tenders.php", name: "Tenders Info" }, + { url: "https://www.globaltenders.com/government-tenders-singapore", name: "Global Tenders" }, +] as const;- JSON.stringify({ - success: true, - links: [ - { url: 'https://www.gebiz.gov.sg/', name: 'GeBIZ' }, - { url: 'https://www.tendersontime.com/singapore-tenders/', name: 'Tenders On Time' }, - { url: 'https://www.biddetail.com/singapore-tenders', name: 'Bid Detail' }, - { url: 'https://www.tendersinfo.com/global-singapore-tenders.php', name: 'Tenders Info' }, - { url: 'https://www.globaltenders.com/government-tenders-singapore', name: 'Global Tenders' }, - ] - }), + JSON.stringify({ success: true, links: DEFAULT_LINKS }),- links = [ - { url: 'https://www.gebiz.gov.sg/', name: 'GeBIZ' }, - { url: 'https://www.tendersontime.com/singapore-tenders/', name: 'Tenders On Time' }, - { url: 'https://www.biddetail.com/singapore-tenders', name: 'Bid Detail' }, - { url: 'https://www.tendersinfo.com/global-singapore-tenders.php', name: 'Tenders Info' }, - { url: 'https://www.globaltenders.com/government-tenders-singapore', name: 'Global Tenders' }, - ]; + links = [...DEFAULT_LINKS];- links: [ - { url: 'https://www.gebiz.gov.sg/', name: 'GeBIZ' }, - { url: 'https://www.tendersontime.com/singapore-tenders/', name: 'Tenders On Time' }, - { url: 'https://www.biddetail.com/singapore-tenders', name: 'Bid Detail' }, - { url: 'https://www.tendersinfo.com/global-singapore-tenders.php', name: 'Tenders Info' }, - { url: 'https://www.globaltenders.com/government-tenders-singapore', name: 'Global Tenders' }, - ] + links: DEFAULT_LINKSAlso applies to: 81-87, 102-108
tenders-finder/src/components/NavLink.tsx (1)
11-22: SupportclassNamecallbacks for full NavLink parity.React Router v6's NavLink accepts
classNameas either a string or a callback function receiving{ isActive, isPending }and returning a string. The current wrapper restrictsclassNameto string only, preventing consumers from using the callback form.To support the full NavLink API, update the interface to accept
NavLinkProps["className"]and handle both string and function forms:Proposed implementation
-interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> { - className?: string; +interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> { + className?: NavLinkProps["className"]; activeClassName?: string; pendingClassName?: string; } @@ - className={({ isActive, isPending }) => - cn(className, isActive && activeClassName, isPending && pendingClassName) - } + className={({ isActive, isPending }) => { + const baseClassName = + typeof className === "function" ? className({ isActive, isPending }) : className; + return cn(baseClassName, isActive && activeClassName, isPending && pendingClassName); + }}tenders-finder/supabase/functions/mino-tender-search/index.ts (2)
102-175: Consider explicit reader cleanup on errors.If an error occurs during stream processing, the reader should be explicitly released to avoid resource leaks in long-running scenarios.
Suggested pattern
if (reader) { + try { while (true) { const { done, value } = await reader.read(); if (done) break; // ... processing ... } + } finally { + reader.releaseLock(); + } }
1-4: Wildcard CORS origin for production consideration.Using
'Access-Control-Allow-Origin': '*'is acceptable for development but should be restricted to specific origins in production deployments.tenders-finder/src/components/tender/CompareModal.tsx (1)
32-44: Consider adding keyboard accessibility.The modal lacks keyboard escape handling and focus management. Users should be able to close the modal with the Escape key, and focus should be trapped within the modal while open.
Suggested addition
// Add useEffect for escape key handling React.useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; if (isOpen) { document.addEventListener('keydown', handleEscape); return () => document.removeEventListener('keydown', handleEscape); } }, [isOpen, onClose]);Consider using a Dialog primitive from Radix UI for built-in accessibility features.
tenders-finder/src/hooks/useTenderSearch.ts (1)
42-55: Fail fast when Supabase env vars are missingIf the env vars are absent, the fetch URL becomes invalid and errors are opaque. A guard gives a clearer failure mode.
🛠️ Suggested guard
const startSearch = useCallback(async (sector: Sector, links: string[]) => { + if (!SUPABASE_URL || !SUPABASE_ANON_KEY) { + const erroredAgents = createAgentsFromLinks(links).map(agent => ({ + ...agent, + status: 'error' as const, + message: 'Missing Supabase configuration', + })); + setState(prev => ({ + ...prev, + isSearching: false, + selectedSector: sector, + agents: erroredAgents, + tenders: [], + selectedTenders: new Set(), + })); + return; + } // Initialize agents from provided linkstenders-finder/tsconfig.json (1)
2-8: Root config's compiler options are not inherited; consolidate usingextendsto avoid duplication.Neither
tsconfig.app.jsonnortsconfig.node.jsonextends this root config. As a result, thebaseUrlandpathsoptions defined here are unused.tsconfig.app.jsonredefines them directly, whiletsconfig.node.jsondoesn't need them. To eliminate duplication and leverage shared configuration, havetsconfig.app.jsonextend this root config and remove the redundantbaseUrl/pathsdefinitions.tenders-finder/src/pages/NotFound.tsx (1)
1-18: Prefer<Link>for client-side navigation.Line 16-18 uses a plain anchor, which forces a full page reload and bypasses SPA routing. Using
<Link>preserves client-side navigation and app state.🔁 Suggested change
-import { useLocation } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; @@ - <a href="/" className="text-primary underline hover:text-primary/90"> + <Link to="/" className="text-primary underline hover:text-primary/90"> Return to Home - </a> + </Link>tenders-finder/package.json (1)
2-2: Project name doesn't match directory name.The package name
vite_react_shadcn_tsappears to be a template placeholder. Consider renaming it totenders-finderto match the project directory and improve clarity.- "name": "vite_react_shadcn_ts", + "name": "tenders-finder",tenders-finder/src/components/tender/TenderResultCard.tsx (1)
15-27: Consider adding keyboard accessibility for the clickable card.The card is clickable via
onClickbut lacks keyboard support. Users navigating with keyboards won't be able to select tenders. Consider addingrole="button",tabIndex={0}, and anonKeyDownhandler.<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} whileHover={{ scale: 1.01 }} onClick={onToggleSelect} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleSelect(); }}} + role="button" + tabIndex={0} className={cn(tenders-finder/tsconfig.app.json (1)
18-23: TypeScript strict checks are disabled, reducing type safety.All linting options are turned off, which significantly reduces TypeScript's ability to catch bugs at compile time:
strict: falsedisables all strict type-checkingnoImplicitAny: falseallows untyped variablesnoFallthroughCasesInSwitch: falsecan lead to accidental switch fallthrough bugsFor a new project, consider enabling at least
strict: trueto catch issues early. You can incrementally enable other options later./* Linting */ - "strict": false, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noImplicitAny": false, - "noFallthroughCasesInSwitch": false, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true,tenders-finder/src/App.css (1)
1-42: This appears to be Vite template boilerplate CSS.This file contains default Vite template styles (logo animations,
.read-the-docsclass) that don't seem relevant to a Tenders Finder application. Since the project uses Tailwind CSS for styling, consider removing this file or cleaning it up to only include styles actually used by the app.tenders-finder/src/components/ui/accordion.tsx (1)
41-47: ApplyclassNameto the Content wrapper (not just the inner div).
This aligns with expected API behavior for styling the Radix content element.♻️ Suggested adjustment
<AccordionPrimitive.Content ref={ref} - className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" + className={cn( + "overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down", + className + )} {...props} > - <div className={cn("pb-4 pt-0", className)}>{children}</div> + <div className="pb-4 pt-0">{children}</div> </AccordionPrimitive.Content>tenders-finder/src/components/tender/TenderResultsList.tsx (1)
59-74: Consider capping staggered animation delay for large result sets.The staggered delay
index * 0.05will cause the last items to appear very late if the result set is large (e.g., 100 tenders = 5 seconds delay for the last card). Consider capping the delay:♻️ Suggested improvement
<motion.div key={tender.id} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} - transition={{ delay: index * 0.05 }} + transition={{ delay: Math.min(index * 0.05, 0.5) }} >tenders-finder/src/pages/Index.tsx (1)
127-133: CompareButton may show inappropriately in non-search views.The
CompareButtonis rendered whenevertenders.length > 0, but this condition doesn't account for the current view state. If tenders exist from a previous search, the button will appear even in theselectororconfigviews untilresetSearch()is called.Consider adding a view check:
♻️ Suggested fix
{/* Compare Button - only show when we have results */} - {tenders.length > 0 && ( + {view === 'search' && tenders.length > 0 && ( <CompareButton selectedCount={selectedTenders.size} onCompare={handleCompare} /> )}tenders-finder/src/components/tender/LinkConfigPage.tsx (2)
56-65: Extract duplicated fallback URLs to a constant.The same fallback URLs are repeated three times (lines 58-64, 83-89, 104-110). This violates DRY and increases maintenance burden if the fallback list needs updating.
♻️ Proposed refactor
+const FALLBACK_TENDER_LINKS = [ + 'https://www.gebiz.gov.sg/', + 'https://www.tendersontime.com/singapore-tenders/', + 'https://www.biddetail.com/singapore-tenders', + 'https://www.tendersinfo.com/global-singapore-tenders.php', + 'https://www.globaltenders.com/government-tenders-singapore', +]; + export function LinkConfigPage({ sector, onBack, onStartSearch }: LinkConfigPageProps) { // ... const fetchAILinks = async (count: number = 5): Promise<string[]> => { try { // ... } catch (error) { console.error('Error fetching AI links:', error); - return [ - 'https://www.gebiz.gov.sg/', - // ... repeated URLs - ]; + return FALLBACK_TENDER_LINKS; } };Then use
FALLBACK_TENDER_LINKSin all three locations.Also applies to: 83-91, 104-111
52-54: Consider adding type safety for API response.The
anytype cast on line 53 bypasses TypeScript's type checking. Consider defining an interface for the expected response shape.♻️ Suggested improvement
+interface DiscoverLinksResponse { + links: Array<{ url: string }>; +} + const fetchAILinks = async (count: number = 5): Promise<string[]> => { try { - const { data, error } = await supabase.functions.invoke('discover-tender-links', { + const { data, error } = await supabase.functions.invoke<DiscoverLinksResponse>('discover-tender-links', { body: { sector, limit: count } }); if (error) throw error; if (data?.links) { - return data.links.map((link: any) => link.url); + return data.links.map((link) => link.url); }
| VITE_SUPABASE_PROJECT_ID="dksfgbuuciwhicmpdkys" | ||
| VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRrc2ZnYnV1Y2l3aGljbXBka3lzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjkyNTE5NDIsImV4cCI6MjA4NDgyNzk0Mn0.vgKY9Gc5qtZOP5ZZOzGvKihqLgWXrwMSbTYYpSCFpEg" | ||
| VITE_SUPABASE_URL="https://dksfgbuuciwhicmpdkys.supabase.co" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove committed secrets from .env and rotate the exposed key.
The publishable key (JWT) is in version control, which can enable unauthorized access. Replace with placeholders, add .env to .gitignore, and rotate/revoke the exposed key in Supabase.
🔒 Proposed fix (keep only placeholders in repo)
-VITE_SUPABASE_PROJECT_ID="dksfgbuuciwhicmpdkys"
-VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRrc2ZnYnV1Y2l3aGljbXBka3lzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjkyNTE5NDIsImV4cCI6MjA4NDgyNzk0Mn0.vgKY9Gc5qtZOP5ZZOzGvKihqLgWXrwMSbTYYpSCFpEg"
-VITE_SUPABASE_URL="https://dksfgbuuciwhicmpdkys.supabase.co"
+VITE_SUPABASE_PROJECT_ID=""
+VITE_SUPABASE_PUBLISHABLE_KEY=""
+VITE_SUPABASE_URL=""🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 1-1: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
[warning] 2-2: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
[warning] 3-3: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
🪛 Gitleaks (8.30.0)
[high] 2-2: Uncovered a JSON Web Token, which may lead to unauthorized access to web applications and sensitive user data.
(jwt)
🤖 Prompt for AI Agents
In `@tenders-finder/.env` around lines 1 - 3, The committed .env contains leaked
secrets (VITE_SUPABASE_PROJECT_ID, VITE_SUPABASE_PUBLISHABLE_KEY,
VITE_SUPABASE_URL); remove these real values and replace them with placeholder
values in the file, add .env to .gitignore, and rotate/revoke the exposed
publishable key in Supabase immediately; ensure any deployment or CI uses secure
secret storage (environment variables or secret manager) and update
documentation to show using placeholders for VITE_SUPABASE_PROJECT_ID,
VITE_SUPABASE_PUBLISHABLE_KEY, and VITE_SUPABASE_URL.
| const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { | ||
| const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color); | ||
|
|
||
| if (!colorConfig.length) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <style | ||
| dangerouslySetInnerHTML={{ | ||
| __html: Object.entries(THEMES) | ||
| .map( | ||
| ([theme, prefix]) => ` | ||
| ${prefix} [data-chart=${id}] { | ||
| ${colorConfig | ||
| .map(([key, itemConfig]) => { | ||
| const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color; | ||
| return color ? ` --color-${key}: ${color};` : null; | ||
| }) | ||
| .join("\n")} | ||
| } | ||
| `, | ||
| ) | ||
| .join("\n"), | ||
| }} | ||
| /> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid dangerouslySetInnerHTML and escape the chart id in selectors.
config values and id can be untrusted; current approach risks HTML/style injection and breaks when id contains special characters. Prefer rendering CSS as text content and escape the selector. Also consider validating color strings if config can come from user input.
🔧 Suggested safer rendering + selector escaping
- return (
- <style
- dangerouslySetInnerHTML={{
- __html: Object.entries(THEMES)
- .map(
- ([theme, prefix]) => `
-${prefix} [data-chart=${id}] {
-${colorConfig
- .map(([key, itemConfig]) => {
- const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
- return color ? ` --color-${key}: ${color};` : null;
- })
- .join("\n")}
-}
-`,
- )
- .join("\n"),
- }}
- />
- );
+ const escapedId = CSS.escape(id);
+ const cssText = Object.entries(THEMES)
+ .map(
+ ([theme, prefix]) => `
+${prefix} [data-chart="${escapedId}"] {
+${colorConfig
+ .map(([key, itemConfig]) => {
+ const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
+ return color ? ` --color-${key}: ${color};` : null;
+ })
+ .join("\n")}
+}
+`,
+ )
+ .join("\n");
+
+ return <style>{cssText}</style>;🧰 Tools
🪛 ast-grep (0.40.5)
[warning] 69-69: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🤖 Prompt for AI Agents
In `@tenders-finder/src/components/ui/chart.tsx` around lines 61 - 88, The
ChartStyle component uses dangerouslySetInnerHTML with unescaped id and config
values, which allows CSS/HTML injection and fails for special characters;
replace the innerHTML approach by producing the stylesheet as plain text child
of the <style> element (i.e., render text content instead of
dangerouslySetInnerHTML), escape the selector id using CSS.escape (or a small
equivalent) when building selectors, and sanitize/validate color strings from
config before including them; update ChartStyle, references to id, THEMES and
colorConfig to use the escaped id and validated color values.
| const useFormField = () => { | ||
| const fieldContext = React.useContext(FormFieldContext); | ||
| const itemContext = React.useContext(FormItemContext); | ||
| const { getFieldState, formState } = useFormContext(); | ||
|
|
||
| const fieldState = getFieldState(fieldContext.name, formState); | ||
|
|
||
| if (!fieldContext) { | ||
| throw new Error("useFormField should be used within <FormField>"); | ||
| } | ||
|
|
||
| const { id } = itemContext; | ||
|
|
||
| return { | ||
| id, | ||
| name: fieldContext.name, | ||
| formItemId: `${id}-form-item`, | ||
| formDescriptionId: `${id}-form-item-description`, | ||
| formMessageId: `${id}-form-item-message`, | ||
| ...fieldState, | ||
| }; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Context validation check is ineffective and misplaced.
Two issues with the error handling:
-
The check
if (!fieldContext)at Line 40 happens aftergetFieldState(fieldContext.name, formState)is already called at Line 38, which will fail or produce undefined behavior whenfieldContext.nameis undefined. -
The check will never throw because the context default value is
{} as FormFieldContextValue(Line 18), and an empty object is truthy.
🐛 Proposed fix: Move and fix the context validation
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
+
+ if (!fieldContext.name) {
+ throw new Error("useFormField should be used within <FormField>");
+ }
+
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
- if (!fieldContext) {
- throw new Error("useFormField should be used within <FormField>");
- }
-
const { id } = itemContext;
return {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const useFormField = () => { | |
| const fieldContext = React.useContext(FormFieldContext); | |
| const itemContext = React.useContext(FormItemContext); | |
| const { getFieldState, formState } = useFormContext(); | |
| const fieldState = getFieldState(fieldContext.name, formState); | |
| if (!fieldContext) { | |
| throw new Error("useFormField should be used within <FormField>"); | |
| } | |
| const { id } = itemContext; | |
| return { | |
| id, | |
| name: fieldContext.name, | |
| formItemId: `${id}-form-item`, | |
| formDescriptionId: `${id}-form-item-description`, | |
| formMessageId: `${id}-form-item-message`, | |
| ...fieldState, | |
| }; | |
| }; | |
| const useFormField = () => { | |
| const fieldContext = React.useContext(FormFieldContext); | |
| const itemContext = React.useContext(FormItemContext); | |
| if (!fieldContext.name) { | |
| throw new Error("useFormField should be used within <FormField>"); | |
| } | |
| const { getFieldState, formState } = useFormContext(); | |
| const fieldState = getFieldState(fieldContext.name, formState); | |
| const { id } = itemContext; | |
| return { | |
| id, | |
| name: fieldContext.name, | |
| formItemId: `${id}-form-item`, | |
| formDescriptionId: `${id}-form-item-description`, | |
| formMessageId: `${id}-form-item-message`, | |
| ...fieldState, | |
| }; | |
| }; |
🤖 Prompt for AI Agents
In `@tenders-finder/src/components/ui/form.tsx` around lines 33 - 54, The
useFormField function calls getFieldState(fieldContext.name, formState) before
validating the contexts and uses a truthy check that never fails; move and
strengthen the validation so you verify both contexts before any access: at the
top of useFormField check that FormFieldContext and FormItemContext values are
present and that fieldContext.name exists (e.g., if fieldContext == null ||
!fieldContext.name) and throw the existing error message if not; only after that
call getFieldState(fieldContext.name, formState) and then extract id from
itemContext — reference symbols: useFormField, FormFieldContext,
FormItemContext, useFormContext, getFieldState, formState.
| >(({ className, value, ...props }, ref) => ( | ||
| <ProgressPrimitive.Root | ||
| ref={ref} | ||
| className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)} | ||
| {...props} | ||
| > |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cat -n tenders-finder/src/components/ui/progress.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 998
🌐 Web query:
Radix UI react-progress ProgressPrimitive Root value prop ARIA documentation
💡 Result:
ProgressPrimitive.Root (@radix-ui/react-progress) — value + ARIA
valueprop type:number | null(no default). Settingvalue={null}puts the progress into an indeterminate state. [1]maxprop type:number(no default). Used as the progress bar’s maximum (i.e., the upper bound for the current value). [1]- ARIA mapping / behavior (progressbar role):
- Radix Progress “adheres to the
progressbarrole requirements.” [1] - For a determinate progressbar, ARIA uses:
aria-valuenow= current numeric value (yourvalue) [2]aria-valuemin/aria-valuemaxdefine the range; if omitted forrole="progressbar", they default to 0 and 100 respectively. [3]
- For an indeterminate progressbar, do not set
aria-valuenow(sovalue={null}is the right model). [2] - A progressbar must have an accessible name via
aria-labeloraria-labelledby. [3]
- Radix Progress “adheres to the
Sources: Radix Progress docs (API + accessibility note) [1], MDN on aria-valuenow (incl. indeterminate guidance) [2], MDN on progressbar role defaults + naming requirement [3].
Forward value and max to ProgressPrimitive.Root (currently dropped).
value is destructured but not passed to Root, breaking ARIA semantics—the progressbar won't have aria-valuenow set, violating accessibility requirements. Additionally, the indicator transform hard-codes 100 - which assumes max is always 100; if a different max is passed, the progress calculation becomes incorrect.
✅ Proposed fix
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
+ value={value}
+ max={props.max || 100}
{...props}
>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| >(({ className, value, ...props }, ref) => ( | |
| <ProgressPrimitive.Root | |
| ref={ref} | |
| className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)} | |
| {...props} | |
| > | |
| >(({ className, value, ...props }, ref) => ( | |
| <ProgressPrimitive.Root | |
| ref={ref} | |
| className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)} | |
| value={value} | |
| max={props.max || 100} | |
| {...props} | |
| > |
🤖 Prompt for AI Agents
In `@tenders-finder/src/components/ui/progress.tsx` around lines 9 - 14, The
Progress component currently destructures value (and may receive max) but drops
them when rendering ProgressPrimitive.Root, breaking ARIA and correctness;
forward both value and max into ProgressPrimitive.Root (e.g., include
value={value} and max={max}) and update the indicator transform/width
calculation (currently using hard-coded "100 -") to compute percentage using
(value / max) * 100 so the rendered indicator and aria-valuenow reflect the
actual progress; ensure you still pass ...props for other attributes and use the
same symbols ProgressPrimitive.Root and the indicator element to locate where to
apply the changes.
| import { useTheme } from "next-themes"; | ||
| import { Toaster as Sonner, toast } from "sonner"; | ||
|
|
||
| type ToasterProps = React.ComponentProps<typeof Sonner>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, verify the actual file content
cat -n tenders-finder/src/components/ui/sonner.tsx | head -20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 1020
🏁 Script executed:
# Check tsconfig settings for UMD globals and jsx settings
find . -name "tsconfig*.json" -o -name "tsconfig*.jsonc" | head -5 | xargs -I {} sh -c 'echo "=== {} ===" && cat {}'Repository: tinyfish-io/tinyfish-cookbook
Length of output: 3257
🏁 Script executed:
# Check for React namespace usage patterns in the codebase
rg -n "React\.(ComponentProps|ElementRef|ComponentPropsWithoutRef|ReactNode)" --type=tsx --type=ts | head -20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 100
🏁 Script executed:
# Check if there are files using React.ComponentProps without importing React
rg -B 5 "React\.ComponentProps" --type=tsx --type=ts | head -40Repository: tinyfish-io/tinyfish-cookbook
Length of output: 100
🏁 Script executed:
# Find tenders-finder tsconfig
find tenders-finder -name "tsconfig*.json" | xargs catRepository: tinyfish-io/tinyfish-cookbook
Length of output: 1607
🏁 Script executed:
# Check for React namespace usage in tenders-finder project without type filter
rg "React\." tenders-finder/src --no-type-list | head -30Repository: tinyfish-io/tinyfish-cookbook
Length of output: 516
🏁 Script executed:
# Check if there's a global React type definition or next-env.d.ts
find tenders-finder -name "next-env.d.ts" -o -name "*.d.ts" | head -10 | xargs cat 2>/dev/nullRepository: tinyfish-io/tinyfish-cookbook
Length of output: 110
🏁 Script executed:
# Search for React namespace usage in tenders-finder without type filtering
grep -r "React\." tenders-finder/src --include="*.tsx" --include="*.ts" | head -20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 2234
🏁 Script executed:
# Check if there's any React import anywhere in tenders-finder
grep -r "^import.*React" tenders-finder/src --include="*.tsx" --include="*.ts" | head -10Repository: tinyfish-io/tinyfish-cookbook
Length of output: 845
🏁 Script executed:
# Check package.json to see if React is actually a dependency
cat tenders-finder/package.json | grep -A 5 -B 5 "dependencies\|devDependencies" | head -40Repository: tinyfish-io/tinyfish-cookbook
Length of output: 751
Add a React import for React.ComponentProps usage.
React is referenced as a namespace but isn't imported. Every other component in the same directory correctly imports React, and this will cause a type-checking error.
🛠️ Proposed fix
+import * as React from "react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import { useTheme } from "next-themes"; | |
| import { Toaster as Sonner, toast } from "sonner"; | |
| type ToasterProps = React.ComponentProps<typeof Sonner>; | |
| import * as React from "react"; | |
| import { useTheme } from "next-themes"; | |
| import { Toaster as Sonner, toast } from "sonner"; | |
| type ToasterProps = React.ComponentProps<typeof Sonner>; |
🤖 Prompt for AI Agents
In `@tenders-finder/src/components/ui/sonner.tsx` around lines 1 - 4, The file
uses React as a type namespace (React.ComponentProps) but does not import React,
causing type errors; add an import for React (e.g., import * as React from
"react") at the top of the module so the type alias ToasterProps =
React.ComponentProps<typeof Sonner> resolves correctly and other JSX/type usages
(ToasterProps, Sonner, toast) continue to work as expected.
| export async function startTenderSearch( | ||
| sector: Sector, | ||
| handlers: MinoEventHandler | ||
| ): Promise<void> { | ||
| const response = await supabase.functions.invoke('mino-tender-search', { | ||
| body: { sector }, | ||
| }); | ||
|
|
||
| if (response.error) { | ||
| throw new Error(response.error.message); | ||
| } | ||
|
|
||
| // The edge function returns SSE-like data | ||
| // We'll process the response data | ||
| const data = response.data; | ||
|
|
||
| if (data && data.agents) { | ||
| for (const agent of data.agents) { | ||
| handlers.onAgentUpdate(agent.id, agent); | ||
|
|
||
| if (agent.tenders && agent.tenders.length > 0) { | ||
| for (const tender of agent.tenders) { | ||
| handlers.onTenderFound(tender); | ||
| } | ||
| } | ||
|
|
||
| if (agent.status === 'complete' || agent.status === 'error') { | ||
| handlers.onAgentComplete(agent.id); | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
API contract mismatch with serverless function.
The startTenderSearch function sends only { sector } to the serverless function, but the mino-tender-search endpoint (in supabase/functions/mino-tender-search/index.ts) validates that both sector and url are required (line 14: if (!sector || !url)), and expects agentId as well.
Additionally, the serverless function returns an SSE stream (text/event-stream), but this code expects response.data to be a JSON object with an agents array. Supabase's functions.invoke doesn't natively handle SSE streaming in this manner.
This will result in:
- A 400 error due to missing
urlparameter - Incorrect parsing of the SSE response even if the validation passed
Suggested approach
Either:
- Refactor the client to call the function once per agent/source URL, passing
{ sector, url, agentId }for each source, or - Modify the serverless function to accept just
sectorand iterate over sources internally, returning a non-streaming JSON response, or - Use native
fetchwith proper SSE handling instead ofsupabase.functions.invoke
🤖 Prompt for AI Agents
In `@tenders-finder/src/lib/api/mino.ts` around lines 21 - 52, startTenderSearch
currently calls supabase.functions.invoke('mino-tender-search') with only {
sector } and assumes a JSON response, but the serverless function requires
sector, url (and agentId) and returns SSE; update startTenderSearch to either
(A) iterate your agent/source list and invoke the function per source passing {
sector, url, agentId } so the server validates and you handle each agent's
response, or (B) replace supabase.functions.invoke with a native
fetch/EventSource-based request to the function URL that sends { sector, url,
agentId } and parse the text/event-stream SSE responses, then call
handlers.onAgentUpdate, handlers.onTenderFound, and handlers.onAgentComplete as
events arrive; locate fixes around startTenderSearch, the
supabase.functions.invoke call, and the code paths that call
handlers.onAgentUpdate/onTenderFound/onAgentComplete.
| const corsHeaders = { | ||
| 'Access-Control-Allow-Origin': '*', | ||
| 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', | ||
| }; | ||
|
|
||
| serve(async (req) => { | ||
| if (req.method === 'OPTIONS') { | ||
| return new Response(null, { headers: corsHeaders }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
CORS Access-Control-Allow-Methods header required for preflight POST requests
💡 Result:
For a CORS preflight (an OPTIONS request) that precedes your actual POST, the browser sends Access-Control-Request-Method: POST to ask permission to use POST. (mdn2.netlify.app)
Your server’s preflight response must allow that method by returning Access-Control-Allow-Methods listing POST (or * when not using credentials). If the requested method isn’t allowed/listed, the browser blocks the request (e.g., “Did not find method in CORS header ‘Access-Control-Allow-Methods’”). (developer.mozilla.org)
Also, if the preflight includes Access-Control-Request-Headers: ..., your response must include Access-Control-Allow-Headers with those headers (otherwise the preflight fails). (developer.mozilla.org)
Example preflight response:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://your-frontend.example
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400Citations:
- 1: https://mdn2.netlify.app/en-us/docs/web/http/headers/access-control-request-method/?utm_source=openai
- 2: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Methods?utm_source=openai
- 3: https://developer.mozilla.org/docs/Web/HTTP/Headers/Access-Control-Allow-Headers?utm_source=openai
Add Access-Control-Allow-Methods in the CORS response.
The browser's CORS preflight checks for this header when handling cross-origin POST requests. Without it, browsers will reject the request with "Did not find method in CORS header 'Access-Control-Allow-Methods'". This will cause all cross-origin POST requests to fail.
🛠️ Proposed fix
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const corsHeaders = { | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', | |
| }; | |
| serve(async (req) => { | |
| if (req.method === 'OPTIONS') { | |
| return new Response(null, { headers: corsHeaders }); | |
| } | |
| const corsHeaders = { | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', | |
| 'Access-Control-Allow-Methods': 'POST, OPTIONS', | |
| }; | |
| serve(async (req) => { | |
| if (req.method === 'OPTIONS') { | |
| return new Response(null, { headers: corsHeaders }); | |
| } |
🤖 Prompt for AI Agents
In `@tenders-finder/supabase/functions/discover-tender-links/index.ts` around
lines 3 - 11, The CORS preflight response in the serve handler is missing
Access-Control-Allow-Methods, causing browsers to reject cross-origin POSTs;
update the corsHeaders object (used in the OPTIONS branch where req.method ===
'OPTIONS') to include an 'Access-Control-Allow-Methods' entry listing the
allowed methods (e.g., 'GET, POST, OPTIONS') so the preflight response returns
the required methods header.
| // Use AI to discover relevant tender links | ||
| const response = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Authorization': `Bearer ${LOVABLE_API_KEY}`, | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| body: JSON.stringify({ | ||
| model: 'google/gemini-3-flash-preview', | ||
| messages: [ | ||
| { | ||
| role: 'system', | ||
| content: `You are an expert at finding government tender and procurement websites. | ||
| Return exactly 5 legitimate tender/procurement websites relevant to the given sector in Singapore. | ||
| Focus on official government portals, established tender aggregators, and industry-specific procurement sites. | ||
| Return as JSON array with 'name' and 'url' fields only.` | ||
| }, | ||
| { | ||
| role: 'user', | ||
| content: `Find 5 tender/procurement websites for the "${sector}" sector in Singapore. | ||
| Include GeBIZ (the official Singapore government procurement portal) and 4 other relevant sites. | ||
| Return JSON only: [{"name": "Site Name", "url": "https://..."}]` | ||
| } | ||
| ], | ||
| temperature: 0.3, | ||
| }), | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's locate and examine the file
find . -name "index.ts" -path "*/discover-tender-links/*" 2>/dev/nullRepository: tinyfish-io/tinyfish-cookbook
Length of output: 139
🌐 Web query:
Deno fetch AbortController timeout support edge functions
💡 Result:
Deno’s fetch() supports request timeouts via abort signals (not a dedicated timeout option):
-
Modern (preferred):
AbortSignal.timeout(ms)— available in Deno v1.20+. It returns a signal that automatically aborts after the given milliseconds;fetch()rejects with aDOMExceptionnamed"TimeoutError". (deno.com)try { const res = await fetch(url, { signal: AbortSignal.timeout(5_000) }); } catch (e) { if (e instanceof DOMException && e.name === "TimeoutError") { /* timed out */ } else throw e; }
-
Fallback (works everywhere
AbortControlleris supported): create anAbortControllerand callabort()fromsetTimeout, then passcontroller.signaltofetch(). (sitepoint.com)
Edge Functions (Deno-based): If your edge runtime is Deno v1.20+ (many “Deno edge functions” platforms are), AbortSignal.timeout() should work the same way; otherwise use the fallback pattern. Supabase Edge Functions, for example, are explicitly Deno-based. (supabase.com)
Citations:
- 1: https://deno.com/blog/v1.20?utm_source=openai
- 2: https://www.sitepoint.com/fetch-api-node-deno-bun/?utm_source=openai
- 3: https://supabase.com/docs/guides/functions?utm_source=openai
🌐 Web query:
Deno edge function timeout handling best practices
💡 Result:
Timeout-handling best practices for Deno-based edge functions
1) Design around platform limits (not just your code)
- Know your wall-clock vs CPU limits. For example, Supabase Edge Functions have a wall-clock max duration (Free: 150s, Paid: 400s) and a per-request CPU-time limit (2s), plus an idle timeout that returns 504 if no response is sent in time. (supabase.com)
- On Deno Deploy / Deno Subhosting, CPU time is limited (e.g., 50–200ms by tier) but waiting on I/O doesn’t count toward CPU time, so you can often wait on network calls—just don’t do heavy compute. (docs.deno.com)
- Don’t assume an isolate will live forever: Deno Deploy may shut down an app after an idle period (between ~5s and 10min) once responses stop sending bytes. (docs.deno.com)
2) Put explicit timeouts on every outbound network call
Use AbortSignal.timeout() for fetch() so a slow upstream can’t consume your whole request budget. (deno.com)
const res = await fetch(url, {
signal: AbortSignal.timeout(5_000),
});Handle timeout separately (it rejects with TimeoutError). (deno.com)
3) Propagate cancellation from the client request
If the client disconnects, stop work:
- Pass the incoming request’s abort signal (when available in your framework/runtime) into downstream operations (DB calls, fetches).
- Also clear timers / intervals and stop streams when aborted (important for SSE/streaming patterns). (Deno Deploy keeps isolates alive as long as response bytes continue flowing.) (docs.deno.com)
4) Send headers early; stream long work when appropriate
Some edge platforms allow long work if you return a streaming response (headers within a deadline, then write bytes later). Netlify documents this pattern (return headers within ~40s; stream body to continue). (edge-functions-examples.netlify.app)
5) Avoid “long single request” architectures
If work might exceed your edge limits:
- Return 202 Accepted quickly + enqueue work elsewhere (queue/job runner) + let clients poll/webhook.
- Move periodic/cleanup tasks into the database/job scheduler when possible (e.g., DB cron), instead of edge requests that can time out. (This advice comes up frequently in Supabase edge discussions, and aligns with their hard runtime limits.) (supabase.com)
6) Budget time across steps (and fail fast)
- Pick an overall request budget (e.g., 8s), then split it (DNS/connect/TLS, upstream fetch, parsing, DB).
- Prefer short per-attempt timeouts + retries with backoff over one big timeout (and ensure idempotency).
7) Reduce CPU spikes (timeouts often show up as “CPU limit” failures)
- Keep JSON (de)serialization, crypto, and large transforms minimal; cache where safe.
- Don’t block the event loop; Deno Subhosting specifically warns against blocking the event loop for >1s. (docs.deno.com)
If you tell me which “Deno edge” you’re using (Deno Deploy, Supabase Edge Functions, Netlify Edge Functions, etc.) and your workload (SSE, webhook handler, cron/cleanup, proxying to slow APIs), I can map these into a concrete timeout budget + code structure.
Citations:
- 1: https://supabase.com/docs/guides/functions/limits?utm_source=openai
- 2: https://docs.deno.com/subhosting/manual/pricing_and_limits/?utm_source=openai
- 3: https://docs.deno.com/deploy/reference/runtime/?utm_source=openai
- 4: https://deno.com/blog/v1.20?utm_source=openai
- 5: https://deno.com/blog/v1.20?utm_source=openai
- 6: https://docs.deno.com/deploy/reference/runtime/?utm_source=openai
- 7: https://edge-functions-examples.netlify.app/example/long-running?utm_source=openai
- 8: https://supabase.com/docs/guides/functions/limits?utm_source=openai
- 9: https://docs.deno.com/subhosting/manual/pricing_and_limits/?utm_source=openai
🏁 Script executed:
# Read the actual file to confirm code matches and check for any existing timeout
cat -n ./tenders-finder/supabase/functions/discover-tender-links/index.ts | head -80Repository: tinyfish-io/tinyfish-cookbook
Length of output: 3615
🏁 Script executed:
# Check for deno.json or supabase config to determine Deno version
fd -e json -e toml -path "*/deno.json*" -o -path "*/supabase.json*" -o -path "*/.denorc*" 2>/dev/null | head -10Repository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
# Look for any deno config or import_map files
find . -maxdepth 2 -name "deno.json*" -o -name "import_map.json" 2>/dev/null | head -5Repository: tinyfish-io/tinyfish-cookbook
Length of output: 55
Add an explicit timeout to the AI fetch request.
Without a timeout, the edge function can hang indefinitely if the AI gateway is slow or unresponsive, consuming the entire wall-clock budget (150s–400s on Supabase) and causing platform timeout failures. Explicit timeouts on all outbound network calls are a critical best practice for Deno edge functions.
🛠️ Proposed fix
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 10_000);
+ let response: Response;
+ try {
const response = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${LOVABLE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'google/gemini-3-flash-preview',
messages: [
{
role: 'system',
content: `You are an expert at finding government tender and procurement websites.
Return exactly 5 legitimate tender/procurement websites relevant to the given sector in Singapore.
Focus on official government portals, established tender aggregators, and industry-specific procurement sites.
Return as JSON array with 'name' and 'url' fields only.`
},
{
role: 'user',
content: `Find 5 tender/procurement websites for the "${sector}" sector in Singapore.
Include GeBIZ (the official Singapore government procurement portal) and 4 other relevant sites.
Return JSON only: [{"name": "Site Name", "url": "https://..."}]`
}
],
temperature: 0.3,
}),
+ signal: controller.signal,
});
+ } finally {
+ clearTimeout(timeoutId);
+ }Alternatively, use the modern AbortSignal.timeout(10_000) pattern if Deno v1.20+ is confirmed, which is cleaner and handles cleanup automatically.
🤖 Prompt for AI Agents
In `@tenders-finder/supabase/functions/discover-tender-links/index.ts` around
lines 35 - 61, Add an explicit timeout to the AI fetch call that requests the
Gemini model: wrap the fetch to
'https://ai.gateway.lovable.dev/v1/chat/completions' (the variable response)
with an AbortController (or use AbortSignal.timeout(10_000) if your Deno runtime
supports it) and pass the controller.signal in the fetch options; ensure the
timeout triggers controller.abort() (or relies on AbortSignal.timeout) after
~10_000ms and handle the abort/timeout error path so the function fails fast
instead of hanging.
| const response = await fetch('https://mino.ai/v1/automation/run-sse', { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'X-API-Key': apiKey, | ||
| }, | ||
| body: JSON.stringify({ url, goal }), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const errorText = await response.text(); | ||
| console.error(`[${agentId}] Mino API error:`, response.status, errorText); | ||
| controller.enqueue(encoder.encode(`data: ${JSON.stringify({ | ||
| type: 'ERROR', | ||
| agentId, | ||
| error: `Mino API error: ${response.status}` | ||
| })}\n\n`)); | ||
| controller.close(); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing timeout for external API call.
The fetch call to https://mino.ai/v1/automation/run-sse has no timeout. If the Mino API hangs or is slow to respond, this serverless function will block indefinitely until the platform times it out, potentially consuming resources unnecessarily.
Suggested fix using AbortController
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 120000); // 2 minute timeout
+
const response = await fetch('https://mino.ai/v1/automation/run-sse', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
},
body: JSON.stringify({ url, goal }),
+ signal: controller.signal,
});
+
+ clearTimeout(timeoutId);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const response = await fetch('https://mino.ai/v1/automation/run-sse', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'X-API-Key': apiKey, | |
| }, | |
| body: JSON.stringify({ url, goal }), | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| console.error(`[${agentId}] Mino API error:`, response.status, errorText); | |
| controller.enqueue(encoder.encode(`data: ${JSON.stringify({ | |
| type: 'ERROR', | |
| agentId, | |
| error: `Mino API error: ${response.status}` | |
| })}\n\n`)); | |
| controller.close(); | |
| return; | |
| } | |
| const abortController = new AbortController(); | |
| const timeoutId = setTimeout(() => abortController.abort(), 120000); // 2 minute timeout | |
| const response = await fetch('https://mino.ai/v1/automation/run-sse', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'X-API-Key': apiKey, | |
| }, | |
| body: JSON.stringify({ url, goal }), | |
| signal: abortController.signal, | |
| }); | |
| clearTimeout(timeoutId); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| console.error(`[${agentId}] Mino API error:`, response.status, errorText); | |
| controller.enqueue(encoder.encode(`data: ${JSON.stringify({ | |
| type: 'ERROR', | |
| agentId, | |
| error: `Mino API error: ${response.status}` | |
| })}\n\n`)); | |
| controller.close(); | |
| return; | |
| } |
🤖 Prompt for AI Agents
In `@tenders-finder/supabase/functions/mino-tender-search/index.ts` around lines
77 - 96, The fetch to 'https://mino.ai/v1/automation/run-sse' lacks a timeout;
wrap the request in an AbortController with a configurable timeout (e.g.,
10–30s), pass controller.signal to fetch, and clear the timeout once a response
is received; on timeout/AbortError, log including agentId and send an ERROR SSE
via controller.enqueue (using encoder) similar to the existing error path, then
controller.close() and return. Ensure you reference the same variables used in
the diff (apiKey, url, goal, agentId, controller, encoder) and only abort the
specific fetch call so other logic continues to work.
| server: { | ||
| host: "::", | ||
| port: 8080, | ||
| hmr: { | ||
| overlay: false, | ||
| }, | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
Vite dev server host configuration security defaults "::" IPv6
💡 Result:
- Vite’s secure default is to bind the dev server to
localhost(server.hostdefault:'localhost'), i.e. reachable only from the local machine. (vite.dev) - Setting
server.hosttotrueor0.0.0.0makes Vite listen on all interfaces (LAN/public reachable). (vite.dev) - Setting
server.hostto"::"binds Node/Vite to the unspecified IPv6 address (all IPv6 interfaces). On many OSes this also accepts IPv4 (dual-stack), effectively similar exposure to0.0.0.0from a security standpoint. (nodejs.org)
Host-header / DNS-rebinding protections (important when you expose the server):
- Vite restricts which
Host:headers it will answer viaserver.allowedHosts(default[]). By default it allowslocalhost,*.localhost, and all IP addresses; setting it totrueis explicitly dangerous (DNS rebinding). (vite.dev) - Vite’s dev-server CORS default only allows origins matching localhost/
127.0.0.1/[::1]; settingserver.cors: trueis also explicitly dangerous. (vite.dev)
Practical takeaway: using --host :: (or host: '::') is not a “safe default”—it deliberately exposes the dev server beyond your machine. If you need LAN access, prefer an explicit allowlist (server.allowedHosts) and keep server.cors restrictive. (vite.dev)
Citations:
- 1: https://vite.dev/config/server-options?utm_source=openai
- 2: https://vite.dev/config/server-options?utm_source=openai
- 3: https://nodejs.org/api/net.html?utm_source=openai
- 4: https://vite.dev/config/server-options?utm_source=openai
- 5: https://vite.dev/config/server-options?utm_source=openai
- 6: https://vite.dev/config/server-options?utm_source=openai
🌐 Web query:
IPv6 "::" binding all interfaces security development server
💡 Result:
Binding a development server to IPv6 :: means “listen on all IPv6 interfaces” (the IPv6 wildcard, like IPv4 0.0.0.0). That is not loopback-only, so it can expose your dev server to other machines on your LAN (and potentially the internet) if routing/firewall rules allow it. [1]
Key security/behavior points
::= all IPv6 interfaces (wildcard). Anyone who can reach your machine over IPv6 may reach the service. This is the same class of risk as binding to0.0.0.0on IPv4. [1]- On many Linux systems, binding to
::may also accept IPv4 connections via IPv4-mapped IPv6 addresses (e.g.,::ffff:192.0.2.1) becauseIPV6_V6ONLYis often off by default (dual-stack socket). So you might accidentally expose the service over both IPv6 and IPv4. [2][3] - OS behavior differs. Whether
::also accepts IPv4 depends onIPV6_V6ONLYdefaults / configuration; some systems default to v6-only while Linux often defaults to dual-stack. [2]
Safer defaults for a dev server
- If you want local-only, bind to:
- IPv6 loopback:
::1 - IPv4 loopback:
127.0.0.1
- IPv6 loopback:
- If you must bind to
::for testing on other devices:- Ensure your host firewall restricts access (e.g., only your LAN / only specific IPs).
- Consider forcing IPv6-only behavior (disable v4-mapped acceptance) using
IPV6_V6ONLY/ the runtime’s equivalent option (many stacks expose this explicitly). [2]
Sources: [1] (binding to all interfaces as a security risk), [2] (Linux dual-stack when binding :: with IPV6_V6ONLY default off), [3] (IPv4-mapped IPv6 explanation / why IPv4 shows under inet6).
🌐 Web query:
Vite localhost vs "::" host configuration best practices
💡 Result:
What localhost vs :: means in Vite
-
server.host: 'localhost'(default) binds Vite to the loopback interface only (meant for “this machine only”). Vite explicitly documents the default as'localhost'. [1]- Gotcha: depending on Node/DNS behavior,
localhostmay resolve to IPv4 (127.0.0.1) or IPv6 (::1), and Vite calls out cases where the resolved address can differ from what it’s listening on. [1]
- Gotcha: depending on Node/DNS behavior,
-
server.host: '::'binds to the IPv6 unspecified address (“all IPv6 interfaces”), analogous to binding IPv4 to0.0.0.0. The IPv6::address is the unspecified / wildcard address used to listen on all interfaces. [5]- Practical gotcha: on some OS/network stacks, binding to
::may also accept IPv4 via dual-stack behavior unless configured otherwise (this is a platform/socket option concern, not Vite-specific).
- Practical gotcha: on some OS/network stacks, binding to
-
server.host: '0.0.0.0'orserver.host: truebinds to all interfaces (LAN/public reachable), which Vite explicitly recommends when you need access from other devices/containers. [1]
Best-practice guidance
-
Default to
localhostfor local-only dev- Use
server.host: 'localhost'(or omit it) when you don’t need other devices to connect. This minimizes accidental exposure. [1]
- Use
-
If you hit IPv4/IPv6 “localhost” weirdness, be explicit
- If your browser resolves
localhostto::1but the server is listening on IPv4 (or vice versa), set an explicit host:server.host: '127.0.0.1'(force IPv4 loopback), orserver.host: '::1'(force IPv6 loopback).
- Vite also notes a Node/DNS ordering issue around
localhostand suggests adjusting Node’s DNS result order in some cases. [1]
- If your browser resolves
-
Use
0.0.0.0/truefor LAN, Docker, remote-device testing- This is the most common “I need to open it on my phone / another computer / inside containers” setting. Vite explicitly says
0.0.0.0ortruelistens on all addresses (including LAN/public). [1]
- This is the most common “I need to open it on my phone / another computer / inside containers” setting. Vite explicitly says
-
Only use
::when you specifically want IPv6 wildcard binding- It’s reasonable in IPv6-first environments, but it can be confusing because:
- clients must connect via IPv6 (unless your platform gives dual-stack behavior),
- URLs may show IPv6 formatting, and
- firewall/routing behavior differs from IPv4.
- If your goal is simply “reachable from other devices,”
0.0.0.0/trueis usually simpler and matches Vite’s documented guidance. [1]
- It’s reasonable in IPv6-first environments, but it can be confusing because:
-
Don’t “solve host issues” by disabling host checks
- If you use custom local domains, prefer
server.allowedHostsentries rather thantrue. - Vite warns that setting
allowedHosts: trueis not recommended because it’s vulnerable to DNS rebinding attacks. [1] - If you control the hostname → IP mapping (e.g.,
/etc/hosts), adding those hosts explicitly is considered safe guidance by Vite maintainers. [2]
- If you use custom local domains, prefer
Quick decision table
| Goal | Recommended server.host |
|---|---|
| Local-only dev | localhost (default) [1] |
| Fix localhost IPv4/IPv6 mismatch | 127.0.0.1 or ::1 (explicit loopback); consider Node DNS ordering note [1] |
| Test from phone/LAN/Docker | 0.0.0.0 or true [1] |
| IPv6-only / IPv6-first lab | :: (know your dual-stack + firewall behavior) [5] |
Sources: Vite server options (host + allowedHosts + DNS note) [1], Vite maintainer guidance on allowedHosts and DNS rebinding context [2], definition/meaning of IPv6 :: unspecified address [5].
Binding the dev server to all IPv6 interfaces defeats Vite's secure default.
Line 9 sets host: "::", which binds to all IPv6 interfaces (the IPv6 wildcard, equivalent to 0.0.0.0). On dual-stack systems, this also accepts IPv4 connections. Vite's default is 'localhost' (loopback only); this configuration explicitly exposes the dev server to the LAN and potentially beyond. Use localhost unless intentionally enabling remote access.
🔒 Safer default with opt-in override
export default defineConfig(({ mode }) => ({
server: {
- host: "::",
+ host: process.env.VITE_DEV_HOST ?? "localhost",
port: 8080,
hmr: {
overlay: false,
},
},📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| server: { | |
| host: "::", | |
| port: 8080, | |
| hmr: { | |
| overlay: false, | |
| }, | |
| }, | |
| server: { | |
| host: process.env.VITE_DEV_HOST ?? "localhost", | |
| port: 8080, | |
| hmr: { | |
| overlay: false, | |
| }, | |
| }, |
🤖 Prompt for AI Agents
In `@tenders-finder/vite.config.ts` around lines 8 - 14, The dev server is
currently bound to all interfaces via server.host set to "::", which exposes the
Vite dev server beyond localhost; update the configuration in vite.config.ts by
changing server.host to 'localhost' (or make it configurable via an opt-in ENV
like VITE_HOST) so the default remains loopback-only; locate the server block
(server: { host: "::", port: 8080, hmr: { overlay: false } }) and replace the
host value or add logic to read an environment flag to only bind to all
interfaces when explicitly requested.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 13
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🤖 Fix all issues with AI agents
In `@tutors-finder/.env`:
- Around line 1-3: Remove the committed .env file from the repository (delete
the file from the repo and stop tracking it), add .env to .gitignore, and
replace the committed file with a .env.example containing placeholder values for
VITE_SUPABASE_PROJECT_ID, VITE_SUPABASE_PUBLISHABLE_KEY, and VITE_SUPABASE_URL;
also update the project README with setup steps for creating a local .env from
.env.example and instruct the team to immediately rotate the exposed Supabase
keys since the publishable key and project ID were committed.
In `@tutors-finder/src/components/ExamSelector.tsx`:
- Line 19: The exams array uses React.ElementType without importing React types;
add a type-only import from React (e.g., import type { ElementType } from
'react') and update the exams declaration to use ElementType instead of
React.ElementType (locate the exams constant in ExamSelector.tsx to make the
change).
In `@tutors-finder/src/components/LocationInput.tsx`:
- Around line 1-21: The file references React.FormEvent in the handleSubmit
function of the LocationInput component but doesn't import React; add a
type-only import for React (e.g., import type React from 'react') at the top of
the file so the type React.FormEvent resolves and compilation succeeds; update
the import list above LocationInput to include this type import without
affecting the JSX runtime.
In `@tutors-finder/src/components/TutorCard.tsx`:
- Around line 23-33: The TutorCard clickable div isn't keyboard-accessible;
update the TutorCard component (the div using onToggleSelect and isSelected) to
behave like a button by adding role="button", tabIndex={0},
aria-pressed={isSelected}, and an onKeyDown handler that invokes onToggleSelect
when Enter or Space is pressed (handle Space by preventing default to avoid page
scroll). Keep the existing onClick and visual styles intact so both mouse and
keyboard users can toggle selection.
In `@tutors-finder/src/components/ui/chart.tsx`:
- Around line 61-88: The generated CSS in ChartStyle is vulnerable and can
produce invalid selectors or CSS injection because id, config keys and color
values are interpolated directly into dangerouslySetInnerHTML; fix ChartStyle by
escaping the id and keys with CSS.escape(id) and CSS.escape(key) when building
selectors and custom property names, validate/whitelist color values (accept
only hex, rgb(a), hsl(a) or CSS variables like --var) and skip any invalid
colors, and stop using dangerouslySetInnerHTML by building a safe string and
passing it as the style element's child (or use createElement with text content)
so the final CSS uses quoted/escaped attribute selectors ([data-chart="..."])
and only safe variable assignments; reference ChartStyle, colorConfig, THEMES
and the mapping that produces `--color-${key}: ${color}` to locate and update
the code.
In `@tutors-finder/src/components/ui/progress.tsx`:
- Around line 9-18: The Progress component is destructuring away the value prop
and not passing it to ProgressPrimitive.Root, which breaks aria attributes and
Radix state; update the component so the received value (the prop named value)
is forwarded into ProgressPrimitive.Root (alongside existing {...props}) so Root
can set aria-valuenow and track progress while leaving
ProgressPrimitive.Indicator's transform logic unchanged.
In `@tutors-finder/src/components/ui/skeleton.tsx`:
- Around line 1-4: The component currently uses the global React namespace for
types (React.HTMLAttributes) which isn't available per tsconfig; change to an
explicit type-only import and use it in the Skeleton signature: add an import
type { HTMLAttributes } from "react" and update the props typing to
HTMLAttributes<HTMLDivElement> in the Skeleton function (keep the existing
destructuring of className and ...props and the cn call intact).
In `@tutors-finder/src/components/ui/sonner.tsx`:
- Around line 1-7: The file uses React.ComponentProps to define ToasterProps but
never imports React; add a React import (preferably a type-only import like
`import type * as React from "react"` to match the codebase pattern) so the
React namespace is defined, then keep the existing ToasterProps and Toaster
components (symbols: ToasterProps, Toaster, React.ComponentProps, useTheme)
unchanged.
In `@tutors-finder/src/hooks/use-toast.ts`:
- Around line 169-177: The useEffect in use-toast is re-subscribing on every
state change because `state` is included in the dependency array; remove `state`
from the dependency array and use an empty array so the effect subscribes once,
relying on the stable `setState` reference to push into `listeners` and for
cleanup to splice the same `setState` out; update the effect signature to
React.useEffect(() => { listeners.push(setState); return () => { const index =
listeners.indexOf(setState); if (index > -1) listeners.splice(index, 1); }; },
[]) so subscriptions are only added/removed once.
In `@tutors-finder/src/hooks/useTutorSearch.ts`:
- Around line 98-205: The agent stream handlers reuse agent IDs and perform
setState even after a new search starts, causing stale updates; add a search
token (e.g., currentSearchId via useRef inside useTutorSearch) that is
incremented/updated when a new search begins, capture that token in each
agentPromises async closure, and before every setState (and before processing
chunks) verify the captured token === currentSearchId.current and skip
processing if not, ensuring agentPromises, agentId and setState only update
state for the active search.
In `@tutors-finder/src/integrations/supabase/client.ts`:
- Around line 5-17: The module currently calls createClient with SUPABASE_URL
and SUPABASE_PUBLISHABLE_KEY directly; add a guard that validates SUPABASE_URL
and SUPABASE_PUBLISHABLE_KEY are present before invoking createClient and, if
missing, throw a clear Error mentioning the missing VITE_SUPABASE_URL and/or
VITE_SUPABASE_PUBLISHABLE_KEY so initialization fails fast; update the top of
this module that defines SUPABASE_URL/SUPABASE_PUBLISHABLE_KEY and perform the
check prior to creating/exporting the supabase client.
In `@tutors-finder/supabase/functions/discover-tutor-websites/index.ts`:
- Around line 54-67: The fetch to
'https://ai.gateway.lovable.dev/v1/chat/completions' currently has no timeout;
modify the call in index.ts to use an AbortController and a short timeout (e.g.,
5–15s): create an AbortController before calling fetch, pass controller.signal
in the fetch options where the Authorization/Content-Type headers and body are
set, start a setTimeout that calls controller.abort() after the chosen timeout,
and clear the timeout when the response arrives; handle the abort error (from
the fetch call) appropriately in the same function so the function returns a
failure response instead of hanging.
In `@tutors-finder/supabase/functions/search-tutors-mino/index.ts`:
- Around line 101-109: The SSE forwarding currently drops blank lines (which
delimit events) because of the if (line.trim()) check; update the loop in the
stream handling that processes buffer/lines (the code that calls decoder.decode,
splits into lines, assigns buffer, and iterates lines) to forward every line
unchanged—call writer.write(encoder.encode(line + '\n')) for each line
(including empty ones) instead of skipping blank lines so event boundaries are
preserved.
🟡 Minor comments (19)
tutors-finder/src/components/ui/carousel.tsx-96-102 (1)
96-102:⚠️ Potential issue | 🟡 MinorRemove the
reInitlistener in cleanup.The
reInitlistener is registered (line 97) but not removed in the cleanup function. Embla Carousel listeners persist acrossreInitevents, so without explicit cleanup, duplicate handlers accumulate when the effect re-runs—which occurs wheneverapioronSelectchanges.Suggested fix
return () => { api?.off("select", onSelect); + api?.off("reInit", onSelect); };tutors-finder/src/components/ui/input-otp.tsx-52-57 (1)
52-57:⚠️ Potential issue | 🟡 MinorHide the decorative dot icon from assistive tech.
The separator dot is decorative and should be removed from the accessibility tree. Although lucide-react icons havearia-hidden="true"by default, explicitly marking it as inert is a best practice for clarity and compatibility.♿ Suggested accessibility tweak
- ({ ...props }, ref) => ( - <div ref={ref} role="separator" {...props}> - <Dot /> + ({ ...props }, ref) => ( + <div ref={ref} role="separator" {...props}> + <Dot aria-hidden="true" focusable="false" /> </div> ),tutors-finder/src/components/ui/input-otp.tsx-24-48 (1)
24-48:⚠️ Potential issue | 🟡 MinorAdd defensive guard for context and slot access to prevent runtime crashes.
Line 29 directly destructures from
inputOTPContext.slots[index]without checking if the context or slot exists. If this component is used outside anInputOTPprovider or with an invalid index, it will throw. Theinput-otpv1.4.x library does not guarantee thatslots[index]is always present.Suggested fix
const inputOTPContext = React.useContext(OTPInputContext); + const slot = inputOTPContext?.slots?.[index]; + if (!slot) { + return null; + } - const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; + const { char, hasFakeCaret, isActive } = slot;tutors-finder/src/components/ui/chart.tsx-92-226 (1)
92-226:⚠️ Potential issue | 🟡 MinorDon’t hide zero values in tooltip; tighten key stability.
item.value && ...drops legitimate0values. Alsokey={item.dataKey}can beundefined, leading to unstable keys.✅ Suggested fix
- {payload.map((item, index) => { + {payload.map((item, index) => { const key = `${nameKey || item.name || item.dataKey || "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); const indicatorColor = color || item.payload.fill || item.color; + const itemKey = `${item.dataKey ?? item.name ?? index}`; return ( <div - key={item.dataKey} + key={itemKey} className={cn( "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", indicator === "dot" && "items-center", )} > @@ - {item.value && ( + {item.value !== undefined && item.value !== null && ( <span className="font-mono font-medium tabular-nums text-foreground"> {item.value.toLocaleString()} </span> )}tutors-finder/src/components/ExamSelector.tsx-38-40 (1)
38-40:⚠️ Potential issue | 🟡 MinorAdd
type="button"to prevent accidental form submission.
When rendered inside a form, the default type issubmit.🔧 Proposed fix
- <button + <button type="button" key={type} onClick={() => onSelect(type)}tutors-finder/src/components/CompareDashboard.tsx-59-61 (1)
59-61:⚠️ Potential issue | 🟡 MinorAdd accessible label to close button.
The icon-only button lacks an accessible label for screen reader users. Consider adding
aria-labelor usingsr-onlytext.♿ Proposed fix for accessibility
- <Button variant="ghost" size="icon" onClick={onClose}> + <Button variant="ghost" size="icon" onClick={onClose} aria-label="Close comparison"> <X className="w-6 h-6" /> </Button>tutors-finder/src/hooks/use-toast.ts-6-6 (1)
6-6:⚠️ Potential issue | 🟡 MinorTOAST_REMOVE_DELAY of ~16.7 minutes seems excessively long.
TOAST_REMOVE_DELAY = 1000000(1,000,000ms ≈ 16.7 minutes) means dismissed toasts remain in state for a very long time before being removed. This is likely unintentional. Typical values are 3-5 seconds.🐛 Proposed fix
const TOAST_LIMIT = 1; -const TOAST_REMOVE_DELAY = 1000000; +const TOAST_REMOVE_DELAY = 5000; // 5 secondstutors-finder/supabase/functions/discover-tutor-websites/index.ts-94-114 (1)
94-114:⚠️ Potential issue | 🟡 MinorFallback only triggers on JSON parse errors, not on empty/invalid AI responses.
The fallback logic at lines 105-113 only executes when
JSON.parsethrows. If the AI returns valid JSON that doesn't match the expected schema (e.g., an empty array, or objects missingname/urlfields),websiteswill be set to that invalid data without validation.🛡️ Proposed fix to validate parsed websites
let websites: { name: string; url: string }[] = []; try { // Try to extract JSON array from the response const jsonMatch = content.match(/\[[\s\S]*\]/); if (jsonMatch) { - websites = JSON.parse(jsonMatch[0]); + const parsed = JSON.parse(jsonMatch[0]); + // Validate that parsed data is an array with valid entries + if (Array.isArray(parsed) && parsed.length > 0 && + parsed.every(w => typeof w.name === 'string' && typeof w.url === 'string')) { + websites = parsed; + } else { + throw new Error('Invalid website data structure'); + } } } catch (parseError) {tutors-finder/src/components/ui/breadcrumb.tsx-69-80 (1)
69-80:⚠️ Potential issue | 🟡 MinorFix BreadcrumbEllipsis displayName typo.
This affects debug tooling and devtools readability.
🛠️ Proposed fix
-BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"; +BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";tutors-finder/src/components/ui/breadcrumb.tsx-7-13 (1)
7-13:⚠️ Potential issue | 🟡 Minor
separatorprop is unused in Breadcrumb.Either wire this prop through a context/child API or remove it to avoid a misleading public surface.
🛠️ Proposed fix (remove unused prop)
const Breadcrumb = React.forwardRef< HTMLElement, - React.ComponentPropsWithoutRef<"nav"> & { - separator?: React.ReactNode; - } + React.ComponentPropsWithoutRef<"nav"> >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);tutors-finder/src/components/ui/card.tsx-17-22 (1)
17-22:⚠️ Potential issue | 🟡 MinorAlign CardTitle ref type with the rendered heading element.
The component renders an
<h3>but the ref is typed asHTMLParagraphElement. Change the ref type toHTMLHeadingElementto match the actual DOM element.🛠️ Proposed fix
-const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( +const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>( ({ className, ...props }, ref) => ( <h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} /> ), );tutors-finder/src/components/ui/alert.tsx-36-43 (1)
36-43:⚠️ Potential issue | 🟡 MinorFix AlertDescription ref and props types to match the rendered
<div>.The component renders a
<div>but is typed asHTMLParagraphElementfor both the ref and props.🛠️ Proposed fix
-const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>( +const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( ({ className, ...props }, ref) => ( <div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} /> ), );tutors-finder/src/components/ui/alert.tsx-29-34 (1)
29-34:⚠️ Potential issue | 🟡 MinorFix AlertTitle ref type to match
<h5>.The ref is typed as
HTMLParagraphElementbut the component renders an<h5>, which should useHTMLHeadingElement.Proposed fix
-const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( +const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>( ({ className, ...props }, ref) => ( <h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} /> ), );tutors-finder/src/components/ui/form.tsx-33-54 (1)
33-54:⚠️ Potential issue | 🟡 MinorGuard
useFormFieldbefore callinggetFieldState.The current
if (!fieldContext)check is ineffective—context defaults to{}(truthy). Additionally,getFieldStateis invoked withfieldContext.name(line 38) before validating it exists, anditemContext.idis destructured (line 44) without null checks. Validate bothfieldContext.nameanditemContext.idbefore callinggetFieldState.🛠️ Proposed fix
const useFormField = () => { const fieldContext = React.useContext(FormFieldContext); const itemContext = React.useContext(FormItemContext); const { getFieldState, formState } = useFormContext(); - - const fieldState = getFieldState(fieldContext.name, formState); - - if (!fieldContext) { + if (!fieldContext?.name) { throw new Error("useFormField should be used within <FormField>"); } + if (!itemContext?.id) { + throw new Error("useFormField should be used within <FormItem>"); + } + + const fieldState = getFieldState(fieldContext.name, formState); const { id } = itemContext;tutors-finder/src/hooks/useTutorSearch.ts-45-53 (1)
45-53:⚠️ Potential issue | 🟡 MinorKeep
state.examin sync when starting a search
startSearchresets state but doesn’t persist theexamargument, sostate.examcan stay stale if callers don’t invokesetExamfirst.🛠️ Proposed fix
- setState((prev) => ({ - ...prev, - location, + setState((prev) => ({ + ...prev, + exam, + location, isSearching: true, isDiscovering: true, agents: [], tutors: [], selectedTutorIds: new Set(), }));tutors-finder/src/components/ui/menubar.tsx-188-188 (1)
188-188:⚠️ Potential issue | 🟡 MinorFix typo:
displaynameshould bedisplayName.The property name is misspelled with a lowercase
n. This will result in the display name not being properly set for React DevTools and error messages.🐛 Proposed fix
-MenubarShortcut.displayname = "MenubarShortcut"; +MenubarShortcut.displayName = "MenubarShortcut";tutors-finder/src/components/ui/sidebar.tsx-54-69 (1)
54-69:⚠️ Potential issue | 🟡 MinorPersisted state is written but never read.
The cookie is set on toggle but never used to initialize state, so the sidebar won’t restore after refresh. Consider hydrating_openfrom the cookie.🧩 Proposed fix
- const [_open, _setOpen] = React.useState(defaultOpen); + const [_open, _setOpen] = React.useState(() => { + if (typeof document === "undefined") return defaultOpen; + const cookie = document.cookie + .split("; ") + .find((row) => row.startsWith(`${SIDEBAR_COOKIE_NAME}=`)) + ?.split("=")[1]; + if (cookie === "true") return true; + if (cookie === "false") return false; + return defaultOpen; + });tutors-finder/src/pages/Index.tsx-19-43 (1)
19-43:⚠️ Potential issue | 🟡 MinorReset compare state when starting or resetting a search.
showComparestaystrueafter a reset, so a new search can auto-open the compare dashboard once two tutors are selected. Clear it when starting or resetting the search.🛠️ Suggested fix
- const handleSearch = (location: string) => { - if (state.exam) { - startSearch(state.exam, location); - } - }; + const handleSearch = (location: string) => { + if (state.exam) { + setShowCompare(false); + startSearch(state.exam, location); + } + }; + + const handleReset = () => { + setShowCompare(false); + resetSearch(); + }; @@ - <Button variant="ghost" size="sm" onClick={resetSearch} className="gap-2"> + <Button variant="ghost" size="sm" onClick={handleReset} className="gap-2">tutors-finder/README.md-42-113 (1)
42-113:⚠️ Potential issue | 🟡 MinorAlign README terminology and code fence with the Tutor Finder stack.
The snippet is JavaScript but fenced as
bash, and the tech stack/diagram still reference “Next.js” and “Tender” terminology. Please update to reflect the Tutors Finder Vite/React app and tutor-centric architecture.✏️ Suggested doc fixes
-```bash +```ts @@ -**Next.js (TypeScript)** +**Vite + React + TypeScript** @@ -ORCH["Tender Search Orchestration Layer<br/>(Next.js API / Server Actions)"] +ORCH["Tutor Search Orchestration Layer<br/>(Serverless API / Functions)"] @@ -DB["SUPABASE<br/>(Cached Tenders & Metadata)"] +DB["SUPABASE<br/>(Cached Tutors & Metadata)"] @@ -DBD["• Cached tender listings<br/>• Deduplicated tenders<br/>• Historical records"] -MINOD["• Parallel web agents<br/>• Browse govt tender portals<br/>• Open tender pages<br/>• Extract structured fields<br/>• SSE streaming updates"] +DBD["• Cached tutor listings<br/>• Deduplicated tutors<br/>• Historical records"] +MINOD["• Parallel web agents<br/>• Browse tutor platforms<br/>• Open tutor pages<br/>• Extract structured fields<br/>• SSE streaming updates"]
🧹 Nitpick comments (10)
tutors-finder/src/components/CompareButton.tsx (2)
29-31: Minor: Button text has trailing space when no items selected.When
selectedCountis 0, the button renders"Compare "with a trailing space before the empty string. Consider trimming or restructuring:✨ Suggested fix
- Compare {selectedCount > 0 ? `(${selectedCount} selected)` : ''} + Compare{selectedCount > 0 ? ` (${selectedCount} selected)` : ''}
23-34: Consider hiding the button when nothing is selected.The compare button is always visible even when
selectedCountis 0. While this aids discoverability, it may add visual clutter. Consider conditionally rendering or disabling the button when there are no selections.✨ Option: Conditionally render the button
+ if (selectedCount === 0) { + return null; + } + return ( <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40">tutors-finder/src/hooks/use-mobile.tsx (1)
9-14: Consider usingevent.matchesfor consistency with the MediaQueryList.The
onChangehandler ignores theMediaQueryListEventand re-querieswindow.innerWidth. Usingevent.matchesis more idiomatic and avoids the redundant width check.♻️ Suggested refactor
React.useEffect(() => { const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - }; + const onChange = (event: MediaQueryListEvent) => { + setIsMobile(event.matches); + }; mql.addEventListener("change", onChange); - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + setIsMobile(mql.matches); return () => mql.removeEventListener("change", onChange); }, []);tutors-finder/src/components/ui/progress.tsx (1)
16-17: Clampvalueto [0, 100] to avoid over/under-fill.If
valuegoes below 0 or above 100, the bar can render outside bounds. A small clamp improves robustness without affecting typical use.♻️ Suggested tweak
- <ProgressPrimitive.Indicator + <ProgressPrimitive.Indicator className="h-full w-full flex-1 bg-primary transition-all" - style={{ transform: `translateX(-${100 - (value || 0)}%)` }} + style={{ + transform: `translateX(-${100 - Math.min(100, Math.max(0, value ?? 0))}%)`, + }} />tutors-finder/src/components/ui/chart.tsx (1)
230-275: Legend keys/labels should be stable and non-empty.
key={item.value}can collide and labels can render empty if config omitslabel. Consider a stable key fallback and a label fallback toitem.valueoritem.name.♻️ Suggested refinement
- {payload.map((item) => { + {payload.map((item) => { const key = `${nameKey || item.dataKey || "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); + const itemKey = `${item.dataKey ?? item.value ?? item.name ?? ""}`; return ( <div - key={item.value} + key={itemKey} className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")} > @@ - {itemConfig?.label} + {itemConfig?.label ?? item.value ?? item.name} </div> ); })}tutors-finder/src/components/ui/toggle-group.tsx (1)
34-37: Variant/size priority may be inverted.The current logic
context.variant || variantgives context priority over item-level props. This means ifToggleGroupsets avariant, individualToggleGroupItemcannot override it. Typically, item-level props should take precedence to allow per-item customization.💡 Proposed fix to prioritize item-level props
toggleVariants({ - variant: context.variant || variant, - size: context.size || size, + variant: variant || context.variant, + size: size || context.size, }),tutors-finder/supabase/functions/discover-tutor-websites/index.ts (1)
92-92: Avoid logging full AI response content in production.Logging the entire AI response at line 92 could expose sensitive or verbose data in production logs. Consider logging only essential metadata or using a debug flag.
♻️ Suggested change
- console.log('AI response:', content); + console.log('AI response received, length:', content.length);tutors-finder/src/components/TutorResultsGrid.tsx (1)
57-85: Consider extracting placeholder card to a separate component.The placeholder card markup (lines 61-82) is fairly verbose. If similar placeholders are used elsewhere or this component grows, extracting it to a
TutorCardPlaceholdercomponent would improve maintainability.tutors-finder/src/components/ui/command.tsx (1)
24-24: Consider simplifying the empty interface extension.The
CommandDialogPropsinterface extendsDialogPropswithout adding any properties. You could use a type alias instead for clarity.♻️ Suggested simplification
-interface CommandDialogProps extends DialogProps {} +type CommandDialogProps = DialogProps;tutors-finder/src/pages/NotFound.tsx (1)
1-18: UseLinkfor internal SPA navigation instead of an anchor tag.React Router's
Linkhandles client-side routing and avoids full page reloads, while<a href>triggers a hard refresh. Since "/" is an internal route, usingLinkfollows React Router best practices.Suggested refactor
-import { useLocation } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; @@ - <a href="/" className="text-primary underline hover:text-primary/90"> + <Link to="/" className="text-primary underline hover:text-primary/90"> Return to Home - </a> + </Link>
| VITE_SUPABASE_PROJECT_ID="hspjxcouamcdqrvqhkgg" | ||
| VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhzcGp4Y291YW1jZHFydnFoa2dnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk2NzI4NzEsImV4cCI6MjA4NTI0ODg3MX0.1A4KloHAiPP6ObODTZnXicntDqMfwDbQP-_FoW4eyUY" | ||
| VITE_SUPABASE_URL="https://hspjxcouamcdqrvqhkgg.supabase.co" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CRITICAL: Secrets committed to version control.
This .env file contains sensitive Supabase credentials (publishable key and project ID) that should never be committed to version control. The static analysis tool correctly flagged a JWT token on line 2. Committing secrets creates serious security risks:
- Unauthorized access to your Supabase project
- Potential data breaches or abuse
- Credentials are now in Git history even if removed later
Immediate action required:
- Remove this file from the repository and add
.envto.gitignore - Rotate the Supabase keys immediately since they've been exposed
- Create a
.env.examplefile with placeholder values instead:VITE_SUPABASE_PROJECT_ID="your-project-id" VITE_SUPABASE_PUBLISHABLE_KEY="your-publishable-key" VITE_SUPABASE_URL="your-supabase-url" - Document in README how developers should set up their local
.envfile
Note on static analysis warnings:
The dotenv-linter warnings about quote characters are false positives and can be safely ignored - Vite environment variables commonly use quotes in .env files.
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 1-1: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
[warning] 2-2: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
[warning] 3-3: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
🪛 Gitleaks (8.30.0)
[high] 2-2: Uncovered a JSON Web Token, which may lead to unauthorized access to web applications and sensitive user data.
(jwt)
🤖 Prompt for AI Agents
In `@tutors-finder/.env` around lines 1 - 3, Remove the committed .env file from
the repository (delete the file from the repo and stop tracking it), add .env to
.gitignore, and replace the committed file with a .env.example containing
placeholder values for VITE_SUPABASE_PROJECT_ID, VITE_SUPABASE_PUBLISHABLE_KEY,
and VITE_SUPABASE_URL; also update the project README with setup steps for
creating a local .env from .env.example and instruct the team to immediately
rotate the exposed Supabase keys since the publishable key and project ID were
committed.
| onSelect: (exam: ExamType) => void; | ||
| } | ||
|
|
||
| const exams: { type: ExamType; label: string; icon: React.ElementType; description: string }[] = [ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, find and examine the ExamSelector.tsx file
fd ExamSelector.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 118
🏁 Script executed:
#!/bin/bash
# Check the imports and content of ExamSelector.tsx
cat -n tutors-finder/src/components/ExamSelector.tsx | head -30Repository: tinyfish-io/tinyfish-cookbook
Length of output: 1484
🏁 Script executed:
#!/bin/bash
# Check TypeScript configuration
cat tutors-finder/tsconfig.jsonRepository: tinyfish-io/tinyfish-cookbook
Length of output: 441
🏁 Script executed:
#!/bin/bash
# Look for jsx and types configuration in tsconfig
rg -n 'jsx|types|skipLibCheck|skipDefaultLibCheck' tutors-finder/tsconfig.jsonRepository: tinyfish-io/tinyfish-cookbook
Length of output: 101
🏁 Script executed:
#!/bin/bash
# Check tsconfig.app.json for JSX configuration
cat tutors-finder/tsconfig.app.jsonRepository: tinyfish-io/tinyfish-cookbook
Length of output: 757
🏁 Script executed:
#!/bin/bash
# Search for any global React type declarations
fd -e d.ts | xargs grep -l "declare.*React" 2>/dev/null || echo "No global React declarations found"Repository: tinyfish-io/tinyfish-cookbook
Length of output: 107
Fix missing React type import for React.ElementType.
Line 19 uses React.ElementType without importing React, which will fail type-checking. Add a type-only import and use ElementType directly.
🛠️ Proposed fix
-import {
+import {
GraduationCap,
BookOpen,
Award,
Target,
Globe,
Microscope,
Trophy,
Calculator
} from 'lucide-react';
+import type { ElementType } from 'react';
import { cn } from '@/lib/utils';
import type { ExamType } from '@/types/tutor';
@@
-const exams: { type: ExamType; label: string; icon: React.ElementType; description: string }[] = [
+const exams: { type: ExamType; label: string; icon: ElementType; description: string }[] = [📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const exams: { type: ExamType; label: string; icon: React.ElementType; description: string }[] = [ | |
| import { | |
| GraduationCap, | |
| BookOpen, | |
| Award, | |
| Target, | |
| Globe, | |
| Microscope, | |
| Trophy, | |
| Calculator | |
| } from 'lucide-react'; | |
| import type { ElementType } from 'react'; | |
| import { cn } from '@/lib/utils'; | |
| import type { ExamType } from '@/types/tutor'; | |
| const exams: { type: ExamType; label: string; icon: ElementType; description: string }[] = [ |
🤖 Prompt for AI Agents
In `@tutors-finder/src/components/ExamSelector.tsx` at line 19, The exams array
uses React.ElementType without importing React types; add a type-only import
from React (e.g., import type { ElementType } from 'react') and update the exams
declaration to use ElementType instead of React.ElementType (locate the exams
constant in ExamSelector.tsx to make the change).
| import { useState } from 'react'; | ||
| import { MapPin, Search, Loader2 } from 'lucide-react'; | ||
| import { Input } from '@/components/ui/input'; | ||
| import { Button } from '@/components/ui/button'; | ||
| import type { ExamType } from '@/types/tutor'; | ||
|
|
||
| interface LocationInputProps { | ||
| exam: ExamType; | ||
| onSearch: (location: string) => void; | ||
| isLoading: boolean; | ||
| } | ||
|
|
||
| export function LocationInput({ exam, onSearch, isLoading }: LocationInputProps) { | ||
| const [location, setLocation] = useState(''); | ||
|
|
||
| const handleSubmit = (e: React.FormEvent) => { | ||
| e.preventDefault(); | ||
| if (location.trim()) { | ||
| onSearch(location.trim()); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# Find and inspect TypeScript configuration
find . -name "tsconfig.json" -o -name "tsconfig.*.json" | head -20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 516
🏁 Script executed:
# Find the LocationInput component file
find . -name "LocationInput.tsx" -type fRepository: tinyfish-io/tinyfish-cookbook
Length of output: 121
🏁 Script executed:
# Check if React is imported elsewhere or if there's a global type definition
rg "React\.FormEvent" --type tsx --type ts -A 2 -B 2Repository: tinyfish-io/tinyfish-cookbook
Length of output: 100
🏁 Script executed:
# Read the TypeScript config for tutors-finder
cat tutors-finder/tsconfig.jsonRepository: tinyfish-io/tinyfish-cookbook
Length of output: 441
🏁 Script executed:
# Read the LocationInput component
cat -n tutors-finder/src/components/LocationInput.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 2518
🏁 Script executed:
# Search for React.FormEvent in the repository
rg "React\.FormEvent" -A 2 -B 2Repository: tinyfish-io/tinyfish-cookbook
Length of output: 2071
🏁 Script executed:
# Check the app-specific tsconfig to see JSX runtime settings
cat tutors-finder/tsconfig.app.jsonRepository: tinyfish-io/tinyfish-cookbook
Length of output: 757
🏁 Script executed:
# Check if React is globally available through type definitions or declare
rg "declare global" --type-add "ts:*.ts" --type-add "tsx:*.tsx" -A 5 tutors-finder/Repository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
# Check vite or build config for JSX settings
find tutors-finder -name "vite.config.*" -o -name "next.config.*" | xargs cat 2>/dev/nullRepository: tinyfish-io/tinyfish-cookbook
Length of output: 566
Fix missing React type import (compile error).
Line 16 references React.FormEvent, but React isn't imported. With jsx: "react-jsx" in tsconfig, the React namespace must be explicitly imported to use type utilities like FormEvent.
✅ Proposed fix
-import { useState } from 'react';
+import { useState, type FormEvent } from 'react';
@@
- const handleSubmit = (e: React.FormEvent) => {
+ const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import { useState } from 'react'; | |
| import { MapPin, Search, Loader2 } from 'lucide-react'; | |
| import { Input } from '@/components/ui/input'; | |
| import { Button } from '@/components/ui/button'; | |
| import type { ExamType } from '@/types/tutor'; | |
| interface LocationInputProps { | |
| exam: ExamType; | |
| onSearch: (location: string) => void; | |
| isLoading: boolean; | |
| } | |
| export function LocationInput({ exam, onSearch, isLoading }: LocationInputProps) { | |
| const [location, setLocation] = useState(''); | |
| const handleSubmit = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (location.trim()) { | |
| onSearch(location.trim()); | |
| } | |
| }; | |
| import { useState, type FormEvent } from 'react'; | |
| import { MapPin, Search, Loader2 } from 'lucide-react'; | |
| import { Input } from '@/components/ui/input'; | |
| import { Button } from '@/components/ui/button'; | |
| import type { ExamType } from '@/types/tutor'; | |
| interface LocationInputProps { | |
| exam: ExamType; | |
| onSearch: (location: string) => void; | |
| isLoading: boolean; | |
| } | |
| export function LocationInput({ exam, onSearch, isLoading }: LocationInputProps) { | |
| const [location, setLocation] = useState(''); | |
| const handleSubmit = (e: FormEvent<HTMLFormElement>) => { | |
| e.preventDefault(); | |
| if (location.trim()) { | |
| onSearch(location.trim()); | |
| } | |
| }; |
🤖 Prompt for AI Agents
In `@tutors-finder/src/components/LocationInput.tsx` around lines 1 - 21, The file
references React.FormEvent in the handleSubmit function of the LocationInput
component but doesn't import React; add a type-only import for React (e.g.,
import type React from 'react') at the top of the file so the type
React.FormEvent resolves and compilation succeeds; update the import list above
LocationInput to include this type import without affecting the JSX runtime.
| export function TutorCard({ tutor, isSelected, onToggleSelect }: TutorCardProps) { | ||
| return ( | ||
| <div | ||
| onClick={onToggleSelect} | ||
| className={cn( | ||
| 'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card', | ||
| 'hover:shadow-lg hover:border-primary/30', | ||
| isSelected | ||
| ? 'border-primary bg-primary/5 shadow-md' | ||
| : 'border-border' | ||
| )} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "TutorCard.tsx" | head -5Repository: tinyfish-io/tinyfish-cookbook
Length of output: 117
🏁 Script executed:
cat -n ./tutors-finder/src/components/TutorCard.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 5888
🏁 Script executed:
# Verify if there are any event handlers or accessibility attributes we might have missed
rg -A 10 "onToggleSelect" ./tutors-finder/src/components/TutorCard.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 565
Make the card keyboard-accessible.
The clickable <div> on line 25 lacks keyboard support, blocking keyboard users and screen reader users from selecting tutors. The element has onClick but no onKeyDown handler, no role attribute, and no tabIndex, making it unfocusable and non-semantic.
♿ Proposed fix
- <div
- onClick={onToggleSelect}
+ <div
+ onClick={onToggleSelect}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onToggleSelect();
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ aria-pressed={isSelected}
className={cn(
'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card',
'hover:shadow-lg hover:border-primary/30',
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border'
)}
>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function TutorCard({ tutor, isSelected, onToggleSelect }: TutorCardProps) { | |
| return ( | |
| <div | |
| onClick={onToggleSelect} | |
| className={cn( | |
| 'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card', | |
| 'hover:shadow-lg hover:border-primary/30', | |
| isSelected | |
| ? 'border-primary bg-primary/5 shadow-md' | |
| : 'border-border' | |
| )} | |
| export function TutorCard({ tutor, isSelected, onToggleSelect }: TutorCardProps) { | |
| return ( | |
| <div | |
| onClick={onToggleSelect} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| onToggleSelect(); | |
| } | |
| }} | |
| role="button" | |
| tabIndex={0} | |
| aria-pressed={isSelected} | |
| className={cn( | |
| 'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card', | |
| 'hover:shadow-lg hover:border-primary/30', | |
| isSelected | |
| ? 'border-primary bg-primary/5 shadow-md' | |
| : 'border-border' | |
| )} |
🤖 Prompt for AI Agents
In `@tutors-finder/src/components/TutorCard.tsx` around lines 23 - 33, The
TutorCard clickable div isn't keyboard-accessible; update the TutorCard
component (the div using onToggleSelect and isSelected) to behave like a button
by adding role="button", tabIndex={0}, aria-pressed={isSelected}, and an
onKeyDown handler that invokes onToggleSelect when Enter or Space is pressed
(handle Space by preventing default to avoid page scroll). Keep the existing
onClick and visual styles intact so both mouse and keyboard users can toggle
selection.
| const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { | ||
| const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color); | ||
|
|
||
| if (!colorConfig.length) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <style | ||
| dangerouslySetInnerHTML={{ | ||
| __html: Object.entries(THEMES) | ||
| .map( | ||
| ([theme, prefix]) => ` | ||
| ${prefix} [data-chart=${id}] { | ||
| ${colorConfig | ||
| .map(([key, itemConfig]) => { | ||
| const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color; | ||
| return color ? ` --color-${key}: ${color};` : null; | ||
| }) | ||
| .join("\n")} | ||
| } | ||
| `, | ||
| ) | ||
| .join("\n"), | ||
| }} | ||
| /> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, find the chart.tsx file and understand its context
find . -name "chart.tsx" -type f | head -20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 254
🏁 Script executed:
# Look for how ChartStyle component is imported and used
rg "ChartStyle" --type tsx --type ts -A 2 -B 2Repository: tinyfish-io/tinyfish-cookbook
Length of output: 100
🏁 Script executed:
# Search for where the `id` prop is passed to ChartStyle
rg "data-chart" --type tsx --type ts -B 5 -A 2Repository: tinyfish-io/tinyfish-cookbook
Length of output: 100
🏁 Script executed:
# Check the broader context of the chart.tsx file to understand exports and structure
head -100 tutors-finder/src/components/ui/chart.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 3360
🏁 Script executed:
# Search for how ChartContainer is used across the repo
rg "ChartContainer" -A 3 -B 1Repository: tinyfish-io/tinyfish-cookbook
Length of output: 4933
🏁 Script executed:
# Look for chart imports and usage patterns
rg "from.*chart" --type ts --type tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 100
🏁 Script executed:
# Check if id/config come from API or user data
rg "chart" -i tutors-finder/src -A 2 | head -80Repository: tinyfish-io/tinyfish-cookbook
Length of output: 5854
🏁 Script executed:
# Find actual usage of ChartContainer in tutors-finder
find tutors-finder -name "*.tsx" -o -name "*.ts" | xargs grep -l "ChartContainer" 2>/dev/nullRepository: tinyfish-io/tinyfish-cookbook
Length of output: 114
🏁 Script executed:
# Look at specific usage examples
rg "ChartContainer" tutors-finder -A 5 -B 2 --max-count=5Repository: tinyfish-io/tinyfish-cookbook
Length of output: 1829
🏁 Script executed:
# Check if configs are from user data or hardcoded
rg "config.*=" tutors-finder/src -A 2 | grep -i "chart\|config" | head -30Repository: tinyfish-io/tinyfish-cookbook
Length of output: 2107
🏁 Script executed:
# Search for actual usage of ChartContainer across all projects
rg "ChartContainer" --type ts --type tsx --max-count=50 | grep -v "\.tsx-"Repository: tinyfish-io/tinyfish-cookbook
Length of output: 100
🏁 Script executed:
# Check for components that import ChartContainer
rg "import.*ChartContainer|from.*chart" tutors-finder/src -A 2 -B 1Repository: tinyfish-io/tinyfish-cookbook
Length of output: 354
🏁 Script executed:
# Look for page or component files that use charts
find tutors-finder/src -name "*.tsx" | xargs grep -l "chart\|Chart" 2>/dev/null | head -10Repository: tinyfish-io/tinyfish-cookbook
Length of output: 114
🏁 Script executed:
# Search more broadly for chart usage
rg "ChartContainer\|BarChart\|LineChart" tutors-finder/src --max-count=20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
# Find all tsx files and grep for Chart components
find tutors-finder/src -type f -name "*.tsx" -exec basename {} \;Repository: tinyfish-io/tinyfish-cookbook
Length of output: 922
🏁 Script executed:
# Look for import statements of chart components
find tutors-finder/src -type f -name "*.tsx" -exec grep -l "from.*chart\|ChartContainer\|LineChart\|BarChart" {} \;Repository: tinyfish-io/tinyfish-cookbook
Length of output: 114
🏁 Script executed:
# Check CompareDashboard.tsx which likely uses charts
cat tutors-finder/src/components/CompareDashboard.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 5475
🏁 Script executed:
# Check App.tsx for chart usage
grep -n "chart\|Chart" tutors-finder/src/components/App.tsx | head -20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 142
🏁 Script executed:
# Look at the full list of exports from chart.tsx to understand the API
grep -n "export" tutors-finder/src/components/ui/chart.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 213
🏁 Script executed:
# Search across ALL projects for actual ChartContainer usage
find . -name "*.tsx" -o -name "*.ts" | xargs grep -l "ChartContainer" 2>/dev/null | grep -v "chart.tsx"Repository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
# Search for any imports from the chart.tsx file across all projects
rg "from.*ui/chart" . --max-count=20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
# Check the actual vulnerability: look at the CSS selector syntax issue
cat -n tutors-finder/src/components/ui/chart.tsx | sed -n '61,88p'Repository: tinyfish-io/tinyfish-cookbook
Length of output: 959
🏁 Script executed:
# More thorough search - look for any component files that use charts
find tutors-finder/src -type f \( -name "*.tsx" -o -name "*.ts" \) -exec grep -l "Chart" {} \;Repository: tinyfish-io/tinyfish-cookbook
Length of output: 114
🏁 Script executed:
# Look in other projects for ChartContainer usage
find . -type f \( -name "*.tsx" -o -name "*.ts" \) -exec grep -l "ChartContainer\|LineChart\|BarChart" {} \; | grep -v node_modules | head -20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 355
🏁 Script executed:
# Check if the chart components are actually exported and used
git log --oneline tutors-finder/src/components/ui/chart.tsx | head -5Repository: tinyfish-io/tinyfish-cookbook
Length of output: 98
🏁 Script executed:
# Check competitor-analysis usage
grep -n "ChartContainer\|LineChart\|BarChart" ./competitor-analysis/app/competitors/page.tsx -B 2 -A 2Repository: tinyfish-io/tinyfish-cookbook
Length of output: 302
🏁 Script executed:
# Check dashboard-layout usage
grep -n "ChartContainer\|LineChart\|BarChart" ./competitor-analysis/components/dashboard-layout.tsx -B 2 -A 2Repository: tinyfish-io/tinyfish-cookbook
Length of output: 508
🏁 Script executed:
# Look at how config is constructed in a real example
cat ./competitor-analysis/components/dashboard-layout.tsx | head -150Repository: tinyfish-io/tinyfish-cookbook
Length of output: 5234
🏁 Script executed:
# Comprehensive search for actual ChartContainer usage across all projects
find . -type f -name "*.tsx" -exec grep -l "ChartContainer" {} \; 2>/dev/null | grep -v "chart.tsx$"Repository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
# Check if the component is used via default exports or re-exports
rg "ChartContainer|BarChart|LineChart" . --type ts --type tsx --max-count=50 2>/dev/null | grep -v "node_modules" | grep -v "\.map" | grep -v "chart\.tsx"Repository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
# Verify the actual CSS selector syntax issue - test if unquoted selector breaks
cat > /tmp/test_css.html << 'EOF'
<!DOCTYPE html>
<html>
<head>
<style>
/* Valid syntax - quoted */
.valid [data-chart="chart-123"] { color: red; }
/* Invalid syntax - unquoted with spaces (will fail) */
.invalid [data-chart=chart 123 with spaces] { color: blue; }
</style>
</head>
<body>
<div class="valid" data-chart="chart-123">Valid selector (quoted)</div>
<div class="invalid" data-chart="chart 123 with spaces">Invalid selector (unquoted with spaces)</div>
</body>
</html>
EOF
cat /tmp/test_css.htmlRepository: tinyfish-io/tinyfish-cookbook
Length of output: 518
🏁 Script executed:
# Check if CSS.escape is a standard API
web_search "CSS.escape browser support MDN"Repository: tinyfish-io/tinyfish-cookbook
Length of output: 123
🌐 Web query:
CSS.escape JavaScript standard browser support documentation
💡 Result:
Authoritative documentation (standard + API docs)
- MDN (Web API reference):
CSS.escape()— syntax, examples, and a browser-compatibility section. [1] - Specification: CSS Object Model (CSSOM) — “The
CSS.escape()Method” (definespartial interface CSS { static DOMString escape(DOMString ident); }). [2]
Browser support (quick reference)
- Can I use:
CSS.escape()— support tables and global usage (shows broad modern support; notably not supported in Internet Explorer, and older Edge versions). [3]
Polyfill (if you need legacy support)
- Mathias Bynens’
css.escapepolyfill (npm) — widely used polyfill implementing the CSSOM-defined behavior. [4]
Sources
[1] MDN — CSS: escape() static method: CSS.escape() https://developer.mozilla.org/en-US/docs/Web/API/CSS/escape_static
[2] W3C TR — CSSOM, §8.1 CSS.escape() https://www.w3.org/TR/2013/WD-cssom-20131205/
[3] Can I use — CSS.escape() https://caniuse.com/wf-css-escape
[4] npm — css.escape polyfill https://www.npmjs.com/package/css.escape
🌐 Web query:
CSS attribute selector syntax unquoted values specification
💡 Result:
In CSS attribute selectors, the value part can be written unquoted only when it parses as a CSS identifier token; otherwise it must be a string (quoted).
- Selectors Level 3 (REC): “Attribute values must be CSS identifiers or strings.” This is why both are valid:
a[hreflang=fr](identifier) andspan[class="example"](string). [1] - Selectors Level 4 states the same rule in token terms: “Attribute values must be
<ident-token>s or<string-token>s.” [2] - What counts as an identifier is defined by CSS Syntax: an ident sequence uses ident-start code points (letter, non-ASCII, or
_) and then ident code points (ident-start, digit, or-), and any code point can be included via CSS escapes. [3] - MDN summarizes the practical implication: attribute selector
<value>is an<ident>or<string>, and must be quoted if it contains spaces or special characters. [4]
Practical rule of thumb
- OK unquoted (identifier):
input[type=checkbox],a[hreflang=en-US],div[data-kind=foo] - Must be quoted (not an identifier, or you don’t want to rely on escapes):
a[href="https://example.com"](contains:and/),*[title="hello world"](space),div[data-x="a=b"](=)
Sources:
[1] Selectors Level 3 (W3C REC 2018) [attribute values must be identifiers or strings]. (w3.org)
[2] Selectors Level 4 (CSSWG draft) [attribute values must be <ident-token> or <string-token>]. (w3c.github.io)
[3] CSS Syntax Module Level 3 (W3C) [ident-start/ident code points; escaping]. (w3.org)
[4] MDN Attribute selectors [value is <ident> or <string>; quote when spaces/special chars]. (developer.mozilla.org)
Citations:
- 1: https://www.w3.org/TR/2018/REC-selectors-3-20181106/?utm_source=openai
- 2: https://w3c.github.io/csswg-drafts/selectors-4/?utm_source=openai
- 3: https://www.w3.org/TR/css-syntax-3/?utm_source=openai
- 4: https://developer.mozilla.org/docs/Web/CSS/Reference/Selectors/Attribute_selectors?utm_source=openai
🏁 Script executed:
# Final check: see if ChartConfig is populated from user data or hardcoded
rg "ChartConfig\|config.*=" competitor-analysis -A 3 -B 1 | head -50Repository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
# Look for API calls that might populate chart config
rg "fetch\|axios\|api" competitor-analysis -A 2 | grep -i "config\|chart" | head -20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 55
Fix CSS selector generation to prevent injection and ensure valid CSS syntax.
The unquoted attribute selector [data-chart=${id}] breaks CSS parsing if id contains spaces or special characters (CSS attribute selectors require quotes for non-identifier values per CSS Selectors spec). Additionally, config keys and color values are interpolated without escaping or validation, enabling CSS/variable injection if derived from untrusted sources. Using dangerouslySetInnerHTML with string concatenation amplifies these risks.
Escape the id and config keys using CSS.escape(), validate colors against common formats, and render the style content directly without dangerouslySetInnerHTML:
Proposed fix
+const escapeCssIdent = (value: string) =>
+ typeof CSS !== "undefined" && CSS.escape ? CSS.escape(value) : value.replace(/[^a-zA-Z0-9_-]/g, "_");
+
+const isSafeColor = (value?: string) =>
+ !!value && /^(#([0-9a-fA-F]{3,8})|rgb(a)?\(|hsl(a)?\(|var\(--)/.test(value);
+
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
- return (
- <style
- dangerouslySetInnerHTML={{
- __html: Object.entries(THEMES)
- .map(
- ([theme, prefix]) => `
-${prefix} [data-chart=${id}] {
-${colorConfig
- .map(([key, itemConfig]) => {
- const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
- return color ? ` --color-${key}: ${color};` : null;
- })
- .join("\n")}
-}
-`,
- )
- .join("\n"),
- }}
- />
- );
+ const safeId = escapeCssIdent(id);
+ const css = Object.entries(THEMES)
+ .map(
+ ([theme, prefix]) => `
+${prefix} [data-chart="${safeId}"] {
+${colorConfig
+ .map(([key, itemConfig]) => {
+ const rawColor = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
+ const color = isSafeColor(rawColor) ? rawColor : undefined;
+ const safeKey = escapeCssIdent(key);
+ return color ? ` --color-${safeKey}: ${color};` : null;
+ })
+ .join("\n")}
+}
+`,
+ )
+ .join("\n");
+
+ return <style>{css}</style>;
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { | |
| const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color); | |
| if (!colorConfig.length) { | |
| return null; | |
| } | |
| return ( | |
| <style | |
| dangerouslySetInnerHTML={{ | |
| __html: Object.entries(THEMES) | |
| .map( | |
| ([theme, prefix]) => ` | |
| ${prefix} [data-chart=${id}] { | |
| ${colorConfig | |
| .map(([key, itemConfig]) => { | |
| const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color; | |
| return color ? ` --color-${key}: ${color};` : null; | |
| }) | |
| .join("\n")} | |
| } | |
| `, | |
| ) | |
| .join("\n"), | |
| }} | |
| /> | |
| ); | |
| }; | |
| const escapeCssIdent = (value: string) => | |
| typeof CSS !== "undefined" && CSS.escape ? CSS.escape(value) : value.replace(/[^a-zA-Z0-9_-]/g, "_"); | |
| const isSafeColor = (value?: string) => | |
| !!value && /^(#([0-9a-fA-F]{3,8})|rgb(a)?\(|hsl(a)?\(|var\(--)/.test(value); | |
| const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { | |
| const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color); | |
| if (!colorConfig.length) { | |
| return null; | |
| } | |
| const safeId = escapeCssIdent(id); | |
| const css = Object.entries(THEMES) | |
| .map( | |
| ([theme, prefix]) => ` | |
| ${prefix} [data-chart="${safeId}"] { | |
| ${colorConfig | |
| .map(([key, itemConfig]) => { | |
| const rawColor = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color; | |
| const color = isSafeColor(rawColor) ? rawColor : undefined; | |
| const safeKey = escapeCssIdent(key); | |
| return color ? ` --color-${safeKey}: ${color};` : null; | |
| }) | |
| .join("\n")} | |
| } | |
| `, | |
| ) | |
| .join("\n"); | |
| return <style>{css}</style>; | |
| }; |
🧰 Tools
🪛 ast-grep (0.40.5)
[warning] 69-69: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🤖 Prompt for AI Agents
In `@tutors-finder/src/components/ui/chart.tsx` around lines 61 - 88, The
generated CSS in ChartStyle is vulnerable and can produce invalid selectors or
CSS injection because id, config keys and color values are interpolated directly
into dangerouslySetInnerHTML; fix ChartStyle by escaping the id and keys with
CSS.escape(id) and CSS.escape(key) when building selectors and custom property
names, validate/whitelist color values (accept only hex, rgb(a), hsl(a) or CSS
variables like --var) and skip any invalid colors, and stop using
dangerouslySetInnerHTML by building a safe string and passing it as the style
element's child (or use createElement with text content) so the final CSS uses
quoted/escaped attribute selectors ([data-chart="..."]) and only safe variable
assignments; reference ChartStyle, colorConfig, THEMES and the mapping that
produces `--color-${key}: ${color}` to locate and update the code.
| React.useEffect(() => { | ||
| listeners.push(setState); | ||
| return () => { | ||
| const index = listeners.indexOf(setState); | ||
| if (index > -1) { | ||
| listeners.splice(index, 1); | ||
| } | ||
| }; | ||
| }, [state]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Incorrect dependency in useEffect causes unnecessary re-subscriptions.
The useEffect dependency array includes state, but state changes on every dispatch, causing the effect to re-run and re-subscribe on every toast update. This leads to unnecessary listener removal and re-addition. The effect should have an empty dependency array since setState is stable.
🐛 Proposed fix
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
- }, [state]);
+ }, []);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| React.useEffect(() => { | |
| listeners.push(setState); | |
| return () => { | |
| const index = listeners.indexOf(setState); | |
| if (index > -1) { | |
| listeners.splice(index, 1); | |
| } | |
| }; | |
| }, [state]); | |
| React.useEffect(() => { | |
| listeners.push(setState); | |
| return () => { | |
| const index = listeners.indexOf(setState); | |
| if (index > -1) { | |
| listeners.splice(index, 1); | |
| } | |
| }; | |
| }, []); |
🤖 Prompt for AI Agents
In `@tutors-finder/src/hooks/use-toast.ts` around lines 169 - 177, The useEffect
in use-toast is re-subscribing on every state change because `state` is included
in the dependency array; remove `state` from the dependency array and use an
empty array so the effect subscribes once, relying on the stable `setState`
reference to push into `listeners` and for cleanup to splice the same `setState`
out; update the effect signature to React.useEffect(() => {
listeners.push(setState); return () => { const index =
listeners.indexOf(setState); if (index > -1) listeners.splice(index, 1); }; },
[]) so subscriptions are only added/removed once.
| const agentPromises = websites.map(async (site, index) => { | ||
| const agentId = `agent-${index}`; | ||
|
|
||
| try { | ||
| const response = await fetch( | ||
| `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/search-tutors-mino`, | ||
| { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| Authorization: `Bearer ${import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY}`, | ||
| }, | ||
| body: JSON.stringify({ | ||
| websiteUrl: site.url, | ||
| websiteName: site.name, | ||
| exam, | ||
| }), | ||
| } | ||
| ); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`HTTP ${response.status}`); | ||
| } | ||
|
|
||
| const reader = response.body?.getReader(); | ||
| if (!reader) throw new Error('No reader'); | ||
|
|
||
| const decoder = new TextDecoder(); | ||
| let buffer = ''; | ||
|
|
||
| while (true) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) break; | ||
|
|
||
| buffer += decoder.decode(value, { stream: true }); | ||
| const lines = buffer.split('\n'); | ||
| buffer = lines.pop() || ''; | ||
|
|
||
| for (const line of lines) { | ||
| if (!line.startsWith('data: ')) continue; | ||
| const jsonStr = line.slice(6).trim(); | ||
| if (jsonStr === '[DONE]') continue; | ||
|
|
||
| try { | ||
| const data = JSON.parse(jsonStr); | ||
|
|
||
| // Update agent status | ||
| setState((prev) => ({ | ||
| ...prev, | ||
| agents: prev.agents.map((a) => | ||
| a.id === agentId | ||
| ? { | ||
| ...a, | ||
| streamingUrl: data.streamingUrl || a.streamingUrl, | ||
| status: data.type === 'COMPLETE' ? 'complete' : 'searching', | ||
| message: data.message || a.message, | ||
| } | ||
| : a | ||
| ), | ||
| })); | ||
|
|
||
| // Add tutors when complete | ||
| if (data.type === 'COMPLETE' && data.resultJson?.tutors) { | ||
| const newTutors: Tutor[] = data.resultJson.tutors.map( | ||
| (t: any, i: number) => ({ | ||
| id: `${agentId}-tutor-${i}`, | ||
| tutorName: t.tutorName || 'Unknown', | ||
| examsTaught: t.examsTaught || [], | ||
| subjects: t.subjects || [], | ||
| teachingMode: t.teachingMode || null, | ||
| location: t.location || null, | ||
| experience: t.experience || null, | ||
| qualifications: t.qualifications || null, | ||
| pricing: t.pricing || null, | ||
| pastResults: t.pastResults || null, | ||
| contactMethod: t.contactMethod || null, | ||
| profileLink: t.profileLink || null, | ||
| sourceWebsite: t.sourceWebsite || site.name, | ||
| }) | ||
| ); | ||
|
|
||
| setState((prev) => ({ | ||
| ...prev, | ||
| tutors: [...prev.tutors, ...newTutors], | ||
| agents: prev.agents.map((a) => | ||
| a.id === agentId ? { ...a, tutors: newTutors, status: 'complete' } : a | ||
| ), | ||
| })); | ||
| } | ||
| } catch (e) { | ||
| // Ignore parse errors for incomplete JSON | ||
| } | ||
| } | ||
| } | ||
| } catch (error) { | ||
| console.error(`Agent ${agentId} error:`, error); | ||
| setState((prev) => ({ | ||
| ...prev, | ||
| agents: prev.agents.map((a) => | ||
| a.id === agentId | ||
| ? { ...a, status: 'error', message: 'Search failed' } | ||
| : a | ||
| ), | ||
| })); | ||
| } | ||
| }); | ||
|
|
||
| await Promise.allSettled(agentPromises); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, let's read the full useTutorSearch.ts file to understand the complete context
fd -n "useTutorSearch.ts" tutors-finder/srcRepository: tinyfish-io/tinyfish-cookbook
Length of output: 303
🏁 Script executed:
#!/bin/bash
# Find and read the hook file
find . -name "useTutorSearch.ts" -type fRepository: tinyfish-io/tinyfish-cookbook
Length of output: 116
🏁 Script executed:
#!/bin/bash
# Search for the useTutorSearch hook file
find tutors-finder/src -name "*.ts" -o -name "*.tsx" | grep -i tutorRepository: tinyfish-io/tinyfish-cookbook
Length of output: 3246
🏁 Script executed:
#!/bin/bash
# Read the full useTutorSearch.ts file
wc -l tutors-finder/src/hooks/useTutorSearch.tsRepository: tinyfish-io/tinyfish-cookbook
Length of output: 118
🏁 Script executed:
#!/bin/bash
# Get full content with line numbers
cat -n tutors-finder/src/hooks/useTutorSearch.tsRepository: tinyfish-io/tinyfish-cookbook
Length of output: 8545
🏁 Script executed:
#!/bin/bash
# Search for startSearch usage across the codebase
rg "startSearch" tutors-finder/src -n -B 2 -A 2Repository: tinyfish-io/tinyfish-cookbook
Length of output: 1414
🏁 Script executed:
#!/bin/bash
# Check for isSearching usage in components
rg "isSearching" tutors-finder/src -n -B 2 -A 2Repository: tinyfish-io/tinyfish-cookbook
Length of output: 6369
🏁 Script executed:
#!/bin/bash
# Check LocationInput to see if it has disabled state when isSearching
cat -n tutors-finder/src/components/LocationInput.tsx | head -50Repository: tinyfish-io/tinyfish-cookbook
Length of output: 2123
🏁 Script executed:
#!/bin/bash
# Check how LocationInput is called in Index.tsx
sed -n '65,75p' tutors-finder/src/pages/Index.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 425
🏁 Script executed:
#!/bin/bash
# Double-check if startSearch is called anywhere else or if there's a race condition
rg "startSearch\|isSearching" tutors-finder/src/pages/Index.tsx -nRepository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
#!/bin/bash
# Verify no AbortController or cancellation mechanism exists
rg "AbortController|AbortSignal|cancel" tutors-finder/src/hooks/useTutorSearch.tsRepository: tinyfish-io/tinyfish-cookbook
Length of output: 55
Guard against stale async updates from overlapping searches
Agent IDs (agent-${index}) are reused across searches, and there's no cancellation or token-based guard. If a new search starts before prior SSE streams finish, in-flight state updates from old promises will still execute and mutate state by matching the reused agent IDs. This can mix tutor results from different searches.
Track an active search ID and skip setState calls from stale searches. The suggested approach using useRef and a searchId check before each setState call would resolve this:
🛠️ Possible fix with a search token guard
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useRef } from 'react';
export function useTutorSearch() {
const [state, setState] = useState<SearchState>({
exam: null,
location: '',
isSearching: false,
isDiscovering: false,
agents: [],
tutors: [],
selectedTutorIds: new Set(),
});
+ const activeSearchId = useRef(0);
const startSearch = useCallback(async (exam: ExamType, location: string) => {
+ const searchId = ++activeSearchId.current;- setState((prev) => ({
+ if (activeSearchId.current !== searchId) return;
+ setState((prev) => ({
...prev,
agents: prev.agents.map((a) =>- setState((prev) => ({
+ if (activeSearchId.current !== searchId) return;
+ setState((prev) => ({
...prev,
agents: prev.agents.map((a) =>- await Promise.allSettled(agentPromises);
- setState((prev) => ({ ...prev, isSearching: false }));
+ await Promise.allSettled(agentPromises);
+ if (activeSearchId.current === searchId) {
+ setState((prev) => ({ ...prev, isSearching: false }));
+ }🤖 Prompt for AI Agents
In `@tutors-finder/src/hooks/useTutorSearch.ts` around lines 98 - 205, The agent
stream handlers reuse agent IDs and perform setState even after a new search
starts, causing stale updates; add a search token (e.g., currentSearchId via
useRef inside useTutorSearch) that is incremented/updated when a new search
begins, capture that token in each agentPromises async closure, and before every
setState (and before processing chunks) verify the captured token ===
currentSearchId.current and skip processing if not, ensuring agentPromises,
agentId and setState only update state for the active search.
| const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL; | ||
| const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY; | ||
|
|
||
| // Import the supabase client like this: | ||
| // import { supabase } from "@/integrations/supabase/client"; | ||
|
|
||
| export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, { | ||
| auth: { | ||
| storage: localStorage, | ||
| persistSession: true, | ||
| autoRefreshToken: true, | ||
| } | ||
| }); No newline at end of file |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cat -n tutors-finder/src/integrations/supabase/client.tsRepository: tinyfish-io/tinyfish-cookbook
Length of output: 782
🌐 Web query:
Supabase JavaScript createClient undefined URL anon key behavior error handling
💡 Result:
What happens if supabaseUrl / supabaseAnonKey are undefined?
createClient(supabaseUrl, supabaseKey) requires both parameters. If either value is missing/undefined, Supabase JS throws immediately (synchronously) with:
Error: supabaseUrl is required.Error: supabaseKey is required.[1]
Supabase documents both parameters as required. [2]
Why this usually happens
Almost always, your environment variables aren’t actually being injected into the runtime that executes createClient, so you end up passing undefined (common with Vite/Next.js/client-side builds). A frequent Vite-specific cause is forgetting the VITE_ prefix, so the values never reach import.meta.env. [3]
Recommended error handling pattern
Validate at module init (or in a factory) and throw a clear error before calling createClient:
import { createClient } from "@supabase/supabase-js";
const url = import.meta.env.VITE_SUPABASE_URL; // or process.env.NEXT_PUBLIC_SUPABASE_URL
const key = import.meta.env.VITE_SUPABASE_ANON_KEY; // or process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
if (!url || !key) {
throw new Error("Missing Supabase env vars: SUPABASE_URL / SUPABASE_ANON_KEY");
}
export const supabase = createClient(url, key);One important “anon key” note (Next.js / frontend)
Only variables prefixed for the client (e.g. NEXT_PUBLIC_ in Next.js, VITE_ in Vite) will be available in browser code—otherwise they’ll be undefined and trigger the errors above. [3]
Sources: GitHub discussion showing the exact thrown errors [1]; Supabase docs for required params [2]; example root-cause/fix via env var injection + Vite prefix [3].
[1] (github.com)
[2] (supabase.com)
[3] (dev.to)
Citations:
- 1: https://github.com/orgs/supabase/discussions/3218?utm_source=openai
- 2: https://supabase.com/docs/reference/javascript/initializing?utm_source=openai
- 3: https://dev.to/kazutora_hattori_66972c88/how-to-fix-supabaseurl-is-required-in-a-supabase-x-vite-project-hdh?utm_source=openai
Add validation for missing Supabase environment variables before initialization.
createClient throws synchronously if VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY is undefined, causing runtime errors that can be difficult to debug. Since this file is auto-generated, apply the validation in the generation source or wrap this module with validation logic:
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
throw new Error("Missing required Supabase environment variables: VITE_SUPABASE_URL and/or VITE_SUPABASE_PUBLISHABLE_KEY");
}This ensures fast failure with a clear error message during module initialization rather than at runtime when the client is actually used.
🤖 Prompt for AI Agents
In `@tutors-finder/src/integrations/supabase/client.ts` around lines 5 - 17, The
module currently calls createClient with SUPABASE_URL and
SUPABASE_PUBLISHABLE_KEY directly; add a guard that validates SUPABASE_URL and
SUPABASE_PUBLISHABLE_KEY are present before invoking createClient and, if
missing, throw a clear Error mentioning the missing VITE_SUPABASE_URL and/or
VITE_SUPABASE_PUBLISHABLE_KEY so initialization fails fast; update the top of
this module that defines SUPABASE_URL/SUPABASE_PUBLISHABLE_KEY and perform the
check prior to creating/exporting the supabase client.
| const response = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Authorization': `Bearer ${LOVABLE_API_KEY}`, | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| body: JSON.stringify({ | ||
| model: 'google/gemini-3-flash-preview', | ||
| messages: [ | ||
| { role: 'user', content: prompt } | ||
| ], | ||
| temperature: 0.7, | ||
| }), | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a timeout to the external API call to prevent hanging requests.
The fetch call to the AI gateway has no timeout configured. If the external service is slow or unresponsive, this function will hang indefinitely until Deno's default timeout kicks in, which could be very long.
🛡️ Proposed fix using AbortController
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
+
const response = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${LOVABLE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'google/gemini-3-flash-preview',
messages: [
{ role: 'user', content: prompt }
],
temperature: 0.7,
}),
+ signal: controller.signal,
});
+
+ clearTimeout(timeoutId);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const response = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${LOVABLE_API_KEY}`, | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| model: 'google/gemini-3-flash-preview', | |
| messages: [ | |
| { role: 'user', content: prompt } | |
| ], | |
| temperature: 0.7, | |
| }), | |
| }); | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout | |
| const response = await fetch('https://ai.gateway.lovable.dev/v1/chat/completions', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${LOVABLE_API_KEY}`, | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| model: 'google/gemini-3-flash-preview', | |
| messages: [ | |
| { role: 'user', content: prompt } | |
| ], | |
| temperature: 0.7, | |
| }), | |
| signal: controller.signal, | |
| }); | |
| clearTimeout(timeoutId); |
🤖 Prompt for AI Agents
In `@tutors-finder/supabase/functions/discover-tutor-websites/index.ts` around
lines 54 - 67, The fetch to 'https://ai.gateway.lovable.dev/v1/chat/completions'
currently has no timeout; modify the call in index.ts to use an AbortController
and a short timeout (e.g., 5–15s): create an AbortController before calling
fetch, pass controller.signal in the fetch options where the
Authorization/Content-Type headers and body are set, start a setTimeout that
calls controller.abort() after the chosen timeout, and clear the timeout when
the response arrives; handle the abort error (from the fetch call) appropriately
in the same function so the function returns a failure response instead of
hanging.
| buffer += decoder.decode(value, { stream: true }); | ||
| const lines = buffer.split('\n'); | ||
| buffer = lines.pop() || ''; | ||
|
|
||
| for (const line of lines) { | ||
| if (line.trim()) { | ||
| // Forward the SSE event to the client | ||
| await writer.write(encoder.encode(line + '\n')); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preserve SSE event boundaries when forwarding the stream.
Blank lines delimit SSE events; the if (line.trim()) filter drops them, which can prevent clients from dispatching events correctly. Forward all lines (including empty ones) or stream raw chunks unchanged.
✅ Minimal fix to keep event delimiters
- for (const line of lines) {
- if (line.trim()) {
- // Forward the SSE event to the client
- await writer.write(encoder.encode(line + '\n'));
- }
- }
+ for (const line of lines) {
+ // Forward the SSE event to the client (including blank lines)
+ await writer.write(encoder.encode(line + '\n'));
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop() || ''; | |
| for (const line of lines) { | |
| if (line.trim()) { | |
| // Forward the SSE event to the client | |
| await writer.write(encoder.encode(line + '\n')); | |
| } | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop() || ''; | |
| for (const line of lines) { | |
| // Forward the SSE event to the client (including blank lines) | |
| await writer.write(encoder.encode(line + '\n')); | |
| } |
🤖 Prompt for AI Agents
In `@tutors-finder/supabase/functions/search-tutors-mino/index.ts` around lines
101 - 109, The SSE forwarding currently drops blank lines (which delimit events)
because of the if (line.trim()) check; update the loop in the stream handling
that processes buffer/lines (the code that calls decoder.decode, splits into
lines, assigns buffer, and iterates lines) to forward every line unchanged—call
writer.write(encoder.encode(line + '\n')) for each line (including empty ones)
instead of skipping blank lines so event boundaries are preserved.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 8
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🤖 Fix all issues with AI agents
In `@tutor-finder/src/components/TutorCard.tsx`:
- Around line 25-33: The div used as the clickable card in the TutorCard
component should be made keyboard- and screen-reader-accessible: add
role="button", tabIndex={0}, aria-pressed={isSelected}, and an onKeyDown handler
that listens for Enter and Space and calls onToggleSelect (for Space call
preventDefault to stop page scroll) so keyboard users can toggle selection;
update the element where onToggleSelect, isSelected and cn are used to include
these attributes and the handler.
In `@tutor-finder/src/components/ui/sidebar.tsx`:
- Around line 138-170: The mobile rendering currently spreads ...props onto
Sheet and doesn't forward ref or merge className/style into SheetContent; update
the mobile branch so SheetContent (not Sheet) receives the forwarded ref and the
component props (merge incoming className with the existing class string and
merge the style object to include "--sidebar-width": SIDEBAR_WIDTH_MOBILE while
preserving any provided styles), and remove spreading ...props onto Sheet;
modify the JSX for SheetContent (used inside the isMobile branch) to accept ref,
{...props} and the merged className and style so consumer attributes (className,
id, data-*, refs) propagate the same way as in the collapsible="none" and
desktop branches.
In `@tutor-finder/src/components/ui/sonner.tsx`:
- Around line 1-2: The sonner integration should be removed to avoid the broken
next-themes dependency and duplicate toasters: delete or stop using the
sonner.tsx module (remove imports of { Toaster as Sonner, toast } and any
useTheme() calls), and in App.tsx keep only your existing custom Toaster from
hooks/use-toast.ts (remove the Sonner component and any calls to toast from
sonner); if you do want a single sonner-based system instead, replace the
useTheme() usage with a Vite/React-compatible theme provider and remove the
custom Toaster, ensuring only one toaster component (Sonner or your Toaster) is
rendered.
In `@tutor-finder/src/hooks/use-toast.ts`:
- Around line 166-177: The effect in useToast currently depends on state causing
listeners to be removed and re-added on every state change; update the
React.useEffect dependency array for the listener registration/cleanup to an
empty array so the effect runs only once on mount and once on unmount, keeping
the listeners array stable and ensuring setState subscriptions
(listeners.push/setState and the cleanup that splices listeners) are not
resubscribed on each state update.
In `@tutor-finder/src/hooks/useTutorSearch.ts`:
- Around line 44-53: startSearch currently kicks off fetch requests without
cancellation; create an AbortController at the start of startSearch (and keep
the current controller in a ref like searchAbortRef), call
searchAbortRef.current?.abort() before creating a new controller to cancel prior
requests, pass controller.signal into all fetch calls started by startSearch,
catch and ignore AbortError in the promise handlers to avoid setState on
unmounted components, and add a cleanup useEffect that aborts the controller on
unmount; refer to startSearch, setState, and any internal fetch calls invoked by
startSearch to make these changes.
- Around line 32-42: resetSearch currently only clears state (setState) but
doesn't cancel ongoing network/search operations; modify the hook to track the
current AbortController (or cancellation token) in a ref (e.g., currentAbortRef)
used by your search routine (the function that initiates searches) and call
currentAbortRef.current?.abort() inside resetSearch before clearing state, then
clear/reset the ref after aborting so no in-flight promise can later call
setState; update the search-start path to create and assign a new
AbortController to that ref for each new search and wire fetch/requests to use
its signal so they can be cancelled.
In `@tutor-finder/supabase/functions/search-tutors-mino/index.ts`:
- Around line 122-124: The SSE error payload is built by interpolating
error.message directly, which can break JSON if it contains quotes/special
chars; instead serialize the whole object with JSON.stringify before sending so
the message is properly escaped—replace the current
writer.write(encoder.encode(`data: {"type":"ERROR","message":"${error instanceof
Error ? error.message : 'Unknown error'}"}\n\n`)) usage with constructing an
object (e.g., { type: "ERROR", message: error instanceof Error ? error.message :
"Unknown error" }), JSON.stringify(...) and then write `data: ${json}\n\n` via
writer.write and encoder.encode to ensure valid SSE JSON.
- Around line 105-110: The SSE forwarding loop currently splits the incoming
stream by single newlines and writes each non-empty line via
writer.write(encoder.encode(line + '\n')), which strips required SSE event
boundaries; change the parsing to split incoming chunks on '\n\n' to extract
full SSE events (instead of splitting on '\n'), and when forwarding each event
use the proper '\n\n' delimiter (e.g., rewrite each event string and call
writer.write(encoder.encode(event + '\n\n'))). Update the code that produces
"lines" to produce "events" split by '\n\n' and keep references to writer and
encoder (and the loop that iterates over lines/events) so event boundaries
conform to the SSE protocol.
🟡 Minor comments (16)
tutor-finder/src/components/ui/alert.tsx-29-34 (1)
29-34:⚠️ Potential issue | 🟡 MinorFix ref type mismatch: declares
HTMLParagraphElementbut renders<h5>.The forwardRef type annotation specifies
HTMLParagraphElement, but the component renders an<h5>heading element. This will cause TypeScript errors if consumers pass a ref expecting heading element methods.🔧 Proposed fix
-const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( +const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>( ({ className, ...props }, ref) => ( <h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} /> ), );tutor-finder/src/components/ui/alert.tsx-36-41 (1)
36-41:⚠️ Potential issue | 🟡 MinorFix ref type mismatch: declares
HTMLParagraphElementbut renders<div>.Similar issue here—the ref is typed as
HTMLParagraphElementbut the component renders a<div>.🔧 Proposed fix
-const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>( +const AlertDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( ({ className, ...props }, ref) => ( <div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} /> ), );tutor-finder/src/components/CompareDashboard.tsx-59-61 (1)
59-61:⚠️ Potential issue | 🟡 MinorAdd accessible label to close button.
The close button only contains an icon without a text label or
aria-label, making it inaccessible to screen reader users.♿ Proposed fix
- <Button variant="ghost" size="icon" onClick={onClose}> + <Button variant="ghost" size="icon" onClick={onClose} aria-label="Close comparison"> <X className="w-6 h-6" /> </Button>tutor-finder/src/components/ui/card.tsx-17-22 (1)
17-22:⚠️ Potential issue | 🟡 MinorType mismatch in CardTitle ref and element types.
The ref type is
HTMLParagraphElementbut the component renders an<h3>element (which should useHTMLHeadingElement). This could cause TypeScript issues when consumers try to access heading-specific properties on the ref.🔧 Proposed fix
-const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( +const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>( ({ className, ...props }, ref) => ( <h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} /> ), );tutor-finder/src/components/ui/breadcrumb.tsx-69-81 (1)
69-81:⚠️ Potential issue | 🟡 MinorFix displayName typo.
BreadcrumbElipssisshould beBreadcrumbEllipsis.🔧 Suggested fix
-BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"; +BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";tutor-finder/src/components/ui/breadcrumb.tsx-7-12 (1)
7-12:⚠️ Potential issue | 🟡 MinorRemove or implement the
separatorprop.It’s part of the public API but unused, which is misleading for consumers.
🧹 Suggested cleanup
const Breadcrumb = React.forwardRef< HTMLElement, - React.ComponentPropsWithoutRef<"nav"> & { - separator?: React.ReactNode; - } + React.ComponentPropsWithoutRef<"nav"> >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);tutor-finder/src/components/ui/input-otp.tsx-24-49 (1)
24-49:⚠️ Potential issue | 🟡 MinorGuard against missing OTP context or invalid slot index.
OTPInputContextcan be undefined ifInputOTPSlotis used outside of anInputOTPcomponent tree, causing a crash on destructure. WhileInputOTPSlotis intended for use withinInputOTP, adding a defensive guard would prevent unexpected failures and improve robustness.💡 Suggested safeguard
- const inputOTPContext = React.useContext(OTPInputContext); - const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; + const inputOTPContext = React.useContext(OTPInputContext); + const slot = inputOTPContext?.slots?.[index]; + + if (!slot) { + return ( + <div + ref={ref} + className={cn( + "relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md", + className, + )} + {...props} + /> + ); + } + + const { char, hasFakeCaret, isActive } = slot;tutor-finder/src/components/ui/carousel.tsx-91-103 (1)
91-103:⚠️ Potential issue | 🟡 MinorMissing cleanup for "reInit" event listener.
The effect subscribes to both
reInitandselectevents but only unsubscribes fromselectin the cleanup function. This can cause memory leaks or stale callback invocations if the carousel is unmounted orapi/onSelectchanges.🔧 Proposed fix
return () => { + api?.off("reInit", onSelect); api?.off("select", onSelect); };tutor-finder/src/components/ui/chart.tsx-210-214 (1)
210-214:⚠️ Potential issue | 🟡 MinorZero values are not displayed in tooltip.
The condition
item.value &&is falsy for0, causing legitimate zero values to be hidden. This could mislead users when viewing charts with zero data points.🐛 Proposed fix
- {item.value && ( + {item.value !== undefined && item.value !== null && ( <span className="font-mono font-medium tabular-nums text-foreground"> {item.value.toLocaleString()} </span> )}tutor-finder/src/test/setup.ts-13-13 (1)
13-13:⚠️ Potential issue | 🟡 Minor
dispatchEventshould return a boolean.Per the
MediaQueryListinterface,dispatchEventreturns aboolean(true if the event was not cancelled). The current no-op returnsundefined.Proposed fix
- dispatchEvent: () => {}, + dispatchEvent: () => true,tutor-finder/src/pages/Index.tsx-104-117 (1)
104-117:⚠️ Potential issue | 🟡 MinorClicking Compare with fewer than 2 selections silently does nothing.
When
CompareButtonis clicked,setShowCompare(true)is called, butCompareDashboardonly renders whenselectedTutors.length >= 2. If a user clicks Compare with 0 or 1 selection, nothing visible happens—this could be confusing.Consider either disabling the button when
selectedCount < 2or showing feedback (e.g., a toast) when the user attempts to compare with insufficient selections.🛠️ Option: Disable button when fewer than 2 selected
<CompareButton selectedCount={state.selectedTutorIds.size} onCompare={() => setShowCompare(true)} + disabled={state.selectedTutorIds.size < 2} />This requires
CompareButtonto accept and handle adisabledprop.tutor-finder/src/components/ui/form.tsx-33-54 (1)
33-54:⚠️ Potential issue | 🟡 MinorMove FormField context validation before
getFieldStateand check forfieldContext?.name.
fieldContextdefaults to an empty object{}(line 18), which is truthy, so the guard on line 40 never triggers. This allowsgetFieldState(fieldContext.name, formState)on line 38 to execute withundefinedas the field name when the hook is called outside<FormField>. Move the validation before callinggetFieldStateand check for thenameproperty instead.Proposed fix
- const fieldState = getFieldState(fieldContext.name, formState); - - if (!fieldContext) { + if (!fieldContext?.name) { throw new Error("useFormField should be used within <FormField>"); } + + const fieldState = getFieldState(fieldContext.name, formState);tutor-finder/src/components/ui/menubar.tsx-185-188 (1)
185-188:⚠️ Potential issue | 🟡 MinorFix
displayNamecasing for MenubarShortcut.
displaynamewon't be picked up by React; usedisplayName(camelCase).Proposed fix
-MenubarShortcut.displayname = "MenubarShortcut"; +MenubarShortcut.displayName = "MenubarShortcut";tutor-finder/src/components/ui/sidebar.tsx-355-369 (1)
355-369:⚠️ Potential issue | 🟡 MinorFix typo in transition property name.
Line 364 uses
transition-[margin,opa], which is not a valid CSS property. The correct property isopacity, so the transition won't work. This typo also appears intenders-finder/,tutors-finder/, andsummer-school-finder/sidebar components.🛠️ Proposed fix
- "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",tutor-finder/src/integrations/supabase/client.ts-5-6 (1)
5-6:⚠️ Potential issue | 🟡 MinorAdd runtime validation for required environment variables.
If
VITE_SUPABASE_URLorVITE_SUPABASE_PUBLISHABLE_KEYare undefined,createClientwill receiveundefinedvalues, leading to cryptic runtime errors. Adding validation provides a clearer error message.🛡️ Proposed fix
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL; const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY; + +if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) { + throw new Error('Missing required Supabase environment variables: VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY'); +}tutor-finder/supabase/functions/discover-tutor-websites/index.ts-96-114 (1)
96-114:⚠️ Potential issue | 🟡 MinorAdd validation for parsed website objects.
The parsed JSON is used directly without verifying that each object has the expected
nameandurlproperties. Malformed AI responses could result in downstream errors or undefined behavior.🛡️ Proposed fix to add validation
try { // Try to extract JSON array from the response const jsonMatch = content.match(/\[[\s\S]*\]/); if (jsonMatch) { - websites = JSON.parse(jsonMatch[0]); + const parsed = JSON.parse(jsonMatch[0]); + // Validate structure + if (Array.isArray(parsed) && parsed.every(w => typeof w.name === 'string' && typeof w.url === 'string')) { + websites = parsed; + } else { + throw new Error('Invalid website structure'); + } } } catch (parseError) {
🧹 Nitpick comments (25)
tutor-finder/src/components/ui/popover.tsx (1)
29-29: Consider exporting additional Radix primitives if needed.Radix UI Popover also provides
PopoverClose,PopoverArrow, andPopoverAnchorcomponents that are sometimes useful. If any consumers of this module need those, you could add them:const PopoverClose = PopoverPrimitive.Close; const PopoverAnchor = PopoverPrimitive.Anchor; export { Popover, PopoverTrigger, PopoverContent, PopoverClose, PopoverAnchor };This is optional and depends on your UI requirements.
tutor-finder/vite.config.ts (1)
8-14: Server binds to all network interfaces.The
host: "::"setting exposes the dev server to all network interfaces, not just localhost. This is useful for testing on external devices but makes the server accessible to other machines on your network.If network exposure is not needed, consider using
localhostfor stricter binding:server: { - host: "::", + host: "localhost", port: 8080,tutor-finder/src/components/ui/pagination.tsx (1)
34-46: Consider addingforwardReftoPaginationLinkfor consistency.
PaginationContentandPaginationItemuseforwardRef, butPaginationLinkdoes not. This inconsistency could cause issues if consumers need to forward refs to the underlying anchor element.♻️ Proposed refactor to add forwardRef
-const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => ( - <a - aria-current={isActive ? "page" : undefined} - className={cn( - buttonVariants({ - variant: isActive ? "outline" : "ghost", - size, - }), - className, - )} - {...props} - /> -); +const PaginationLink = React.forwardRef<HTMLAnchorElement, PaginationLinkProps>( + ({ className, isActive, size = "icon", ...props }, ref) => ( + <a + ref={ref} + aria-current={isActive ? "page" : undefined} + className={cn( + buttonVariants({ + variant: isActive ? "outline" : "ghost", + size, + }), + className, + )} + {...props} + /> + ), +);tutor-finder/eslint.config.js (1)
11-15: Add a Node globals override for tool/config TS files.The config applies browser globals to all
**/*.{ts,tsx}. This project has Node-side TS files (vite.config.ts,vitest.config.ts,tailwind.config.ts,postcss.config.js) that match this glob and will trigger false positives forprocess,__dirname, and other Node globals. Add a config object below the current one to apply Node globals to these files.Suggested override
export default tseslint.config( { ignores: ["dist"] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ["**/*.{ts,tsx}"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "@typescript-eslint/no-unused-vars": "off", }, }, + { + files: [ + "**/*.config.{ts,js}", + "**/vite.config.ts", + "**/vitest.config.ts", + "**/tailwind.config.ts", + "**/postcss.config.ts", + ], + languageOptions: { + globals: globals.node, + }, + }, );tutor-finder/package.json (1)
2-2: Package name doesn't match directory name.The package is named
"vite_react_shadcn_ts"but resides in thetutor-finderdirectory. Consider updating the name to"tutor-finder"for consistency and clarity when referencing the package in logs, error messages, or monorepo tooling.📝 Suggested fix
- "name": "vite_react_shadcn_ts", + "name": "tutor-finder",tutor-finder/src/components/CompareButton.tsx (1)
31-31: Minor: Trailing space in button text when no items selected.When
selectedCountis 0, the button renders"Compare "with a trailing space before the empty string. Consider using a conditional without the space:✨ Suggested fix
- Compare {selectedCount > 0 ? `(${selectedCount} selected)` : ''} + {selectedCount > 0 ? `Compare (${selectedCount} selected)` : 'Compare'}tutor-finder/tailwind.config.ts (1)
101-101: Consider using ESM import instead ofrequire()for consistency.Since this is a TypeScript file using ES module syntax (
import/export), usingrequire()is inconsistent. This may also cause issues with stricter TypeScript configurations or bundlers.✨ Suggested fix
import type { Config } from "tailwindcss"; +import tailwindcssAnimate from "tailwindcss-animate"; export default { // ... rest of config - plugins: [require("tailwindcss-animate")], + plugins: [tailwindcssAnimate], } satisfies Config;tutor-finder/src/components/LocationInput.tsx (1)
35-42: Consider adding an accessible label for the location input.The input relies solely on placeholder text for context. Screen readers benefit from an explicit label. Consider adding
aria-labelor a visually hidden<label>.♿ Proposed accessibility improvement
<Input type="text" placeholder="Enter pincode or city" + aria-label="Location" value={location} onChange={(e) => setLocation(e.target.value)} className="pl-11 h-12 text-base" disabled={isLoading} />tutor-finder/src/components/ExamSelector.tsx (1)
38-47: Consider addingaria-pressedfor accessibility.The buttons function as toggle buttons indicating selection state. Adding
aria-pressedimproves screen reader support.♿ Proposed accessibility improvement
<button key={type} onClick={() => onSelect(type)} + aria-pressed={selectedExam === type} className={cn(tutor-finder/src/components/CompareDashboard.tsx (1)
48-62: Consider adding focus management for modal accessibility.This full-screen modal overlay should trap focus and handle Escape key to close. Without focus management, keyboard users may tab outside the modal or be unable to close it via keyboard.
Consider using Radix Dialog or a focus-trap library to handle:
- Focus trapping within the modal
- Escape key to close
- Return focus to trigger element on close
tutor-finder/src/hooks/useTutorSearch.ts (2)
187-189: Silent catch may hide actionable errors.Swallowing all parse errors makes debugging difficult. Consider logging non-partial JSON errors or distinguishing between expected incomplete chunks and actual malformed data.
💡 Suggested improvement
} catch (e) { - // Ignore parse errors for incomplete JSON + // Log unexpected parse errors (not just incomplete chunks) + if (jsonStr.length > 0) { + console.debug(`Agent ${agentId}: Failed to parse SSE data`, jsonStr.slice(0, 100)); + } }
161-177: Consider validating tutor data shape.The code maps raw response data to
Tutorobjects usinganytype casting. Malformed responses could silently produce invalid tutor objects.Consider adding a validation helper or using a schema validation library (e.g., Zod) to ensure the response matches the expected
Tutorstructure before adding to state.tutor-finder/src/hooks/use-mobile.tsx (1)
8-16: Consider usingmql.matchesdirectly in the change handler.The current implementation queries
window.innerWidthin the change handler, butmql.matchesalready provides this information and would be more consistent with the media query definition.♻️ Suggested simplification
React.useEffect(() => { const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + setIsMobile(mql.matches); }; mql.addEventListener("change", onChange); - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + setIsMobile(mql.matches); return () => mql.removeEventListener("change", onChange); }, []);tutor-finder/src/components/ui/carousel.tsx (1)
70-81: Keyboard navigation ignores vertical orientation.The handler only responds to
ArrowLeft/ArrowRight. For vertical carousels, users typically expectArrowUp/ArrowDownkeys.♻️ Suggested enhancement
const handleKeyDown = React.useCallback( (event: React.KeyboardEvent<HTMLDivElement>) => { - if (event.key === "ArrowLeft") { + const prevKeys = orientation === "horizontal" ? ["ArrowLeft"] : ["ArrowUp"]; + const nextKeys = orientation === "horizontal" ? ["ArrowRight"] : ["ArrowDown"]; + if (prevKeys.includes(event.key)) { event.preventDefault(); scrollPrev(); - } else if (event.key === "ArrowRight") { + } else if (nextKeys.includes(event.key)) { event.preventDefault(); scrollNext(); } }, - [scrollPrev, scrollNext], + [scrollPrev, scrollNext, orientation], );tutor-finder/src/components/ui/sheet.tsx (1)
40-41: Minor: Extra space in class string.There's a double space between
w-3/4andborder-lin the right variant.✏️ Proposed fix
right: - "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",tutor-finder/src/main.tsx (1)
1-5: Consider wrapping with React.StrictMode.The entry point follows standard React 18 patterns. Adding
StrictModehelps catch potential issues during development by enabling additional checks and warnings.♻️ Suggested enhancement
+import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App.tsx"; import "./index.css"; -createRoot(document.getElementById("root")!).render(<App />); +createRoot(document.getElementById("root")!).render( + <StrictMode> + <App /> + </StrictMode> +);tutor-finder/tsconfig.json (1)
9-14: Consider enabling stricter TypeScript checks for a new project.Several type safety options are disabled:
strictNullChecks: false— allows null/undefined to flow unchecked, a common source of runtime errors.noImplicitAny: false— permits implicitanytypes, reducing type coverage.For a greenfield project, enabling these catches bugs at compile time rather than runtime. If relaxed settings are intentional for rapid prototyping, consider tightening them before production.
tutor-finder/src/pages/NotFound.tsx (1)
16-17: Prefer<Link>over<a>for internal navigation.Using a plain anchor tag causes a full page reload. Since the app uses React Router, using
<Link to="/">maintains client-side routing and avoids unnecessary reloads.Proposed fix
+import { useLocation, Link } from "react-router-dom"; -import { useLocation } from "react-router-dom";- <a href="/" className="text-primary underline hover:text-primary/90"> + <Link to="/" className="text-primary underline hover:text-primary/90"> Return to Home - </a> + </Link>tutor-finder/tsconfig.app.json (1)
18-23: Consider enabling stricter TypeScript checks for a new project.All strictness flags are disabled, which reduces type safety benefits:
strict: falsedisables strict null checks, implicit any, and other safety featuresnoImplicitAny: falseallows untyped variables to slip throughFor a greenfield project, starting with
strict: trueis recommended as it's significantly harder to enable later. At minimum, consider enablingnoImplicitAny: trueto catch common type errors.♻️ Suggested stricter configuration
/* Linting */ - "strict": false, + "strict": true, "noUnusedLocals": false, "noUnusedParameters": false, - "noImplicitAny": false, + "noImplicitAny": true, "noFallthroughCasesInSwitch": false,tutor-finder/src/pages/Index.tsx (1)
17-17: Consider memoizingselectedTutorsfor performance.This filter runs on every render. For small lists this is fine, but if the tutor list grows, consider
useMemo:♻️ Optional optimization
+import { useState, useMemo } from 'react'; -import { useState } from 'react'; ... - const selectedTutors = state.tutors.filter((t) => state.selectedTutorIds.has(t.id)); + const selectedTutors = useMemo( + () => state.tutors.filter((t) => state.selectedTutorIds.has(t.id)), + [state.tutors, state.selectedTutorIds] + );tutor-finder/supabase/functions/discover-tutor-websites/index.ts (2)
98-98: Consider a less greedy regex for JSON extraction.The regex
/\[[\s\S]*\]/uses greedy matching, which could capture content beyond the intended JSON array if the AI response contains multiple brackets. A non-greedy pattern would be safer.♻️ Proposed fix
- const jsonMatch = content.match(/\[[\s\S]*\]/); + const jsonMatch = content.match(/\[[\s\S]*?\]/);
1-1: Deno std library version is outdated and uses a legacy import pattern.Version 0.168.0 was released in December 2022 (over 3 years ago). The legacy
deno.land/stdURL pattern has a final version at 0.224.0, but Deno's standard library has since moved to JSR with separate@std/*packages. Consider upgrading to@std/httpvia JSR for a more modern, maintainable approach.tutor-finder/supabase/functions/search-tutors-mino/index.ts (1)
89-89: Same JSON escaping issue here.This error message is hardcoded so it's safe now, but for consistency and defensive coding, use
JSON.stringify.♻️ Proposed fix
- await writer.write(encoder.encode('data: {"type":"ERROR","message":"No response body"}\n\n')); + await writer.write(encoder.encode(`data: ${JSON.stringify({ type: "ERROR", message: "No response body" })}\n\n`));tutor-finder/.env (2)
1-3: Avoid committing.envfiles with actual credentials.Even though
VITE_SUPABASE_PUBLISHABLE_KEYis the public anon key (designed for client-side use), committing.envfiles directly can lead to accidental exposure of sensitive values in the future. Consider:
- Add
.envto.gitignore- Create a
.env.examplewith placeholder values for documentation📝 Example .env.example
VITE_SUPABASE_PROJECT_ID="your-project-id" VITE_SUPABASE_PUBLISHABLE_KEY="your-anon-key" VITE_SUPABASE_URL="https://your-project-id.supabase.co"
1-3: Remove unnecessary quotes from environment variable values.The static analysis tool flagged that quotes in
.envvalues can cause issues with some parsers, as the quotes may be included as part of the value. Most.envparsers don't require quotes for simple values.♻️ Proposed fix
-VITE_SUPABASE_PROJECT_ID="hspjxcouamcdqrvqhkgg" -VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhzcGp4Y291YW1jZHFydnFoa2dnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk2NzI4NzEsImV4cCI6MjA4NTI0ODg3MX0.1A4KloHAiPP6ObODTZnXicntDqMfwDbQP-_FoW4eyUY" -VITE_SUPABASE_URL="https://hspjxcouamcdqrvqhkgg.supabase.co" +VITE_SUPABASE_PROJECT_ID=hspjxcouamcdqrvqhkgg +VITE_SUPABASE_PUBLISHABLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhzcGp4Y291YW1jZHFydnFoa2dnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk2NzI4NzEsImV4cCI6MjA4NTI0ODg3MX0.1A4KloHAiPP6ObODTZnXicntDqMfwDbQP-_FoW4eyUY +VITE_SUPABASE_URL=https://hspjxcouamcdqrvqhkgg.supabase.co
| <div | ||
| onClick={onToggleSelect} | ||
| className={cn( | ||
| 'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card', | ||
| 'hover:shadow-lg hover:border-primary/30', | ||
| isSelected | ||
| ? 'border-primary bg-primary/5 shadow-md' | ||
| : 'border-border' | ||
| )} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
find . -name "TutorCard.tsx" -type fRepository: tinyfish-io/tinyfish-cookbook
Length of output: 161
🏁 Script executed:
cat -n ./tutor-finder/src/components/TutorCard.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 5888
Add keyboard and ARIA support to the clickable card.
The card is a <div> with onClick but lacks keyboard handlers and ARIA semantics, making it inaccessible to keyboard and screen reader users. Add role="button", tabIndex={0}, an onKeyDown handler for Enter/Space keys, and aria-pressed={isSelected} to make the selection toggle keyboard-accessible and semantically correct.
♿ Suggested fix
<div
onClick={onToggleSelect}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onToggleSelect();
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ aria-pressed={isSelected}
className={cn(
'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card',
'hover:shadow-lg hover:border-primary/30',
isSelected
? 'border-primary bg-primary/5 shadow-md'
: 'border-border'
)}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div | |
| onClick={onToggleSelect} | |
| className={cn( | |
| 'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card', | |
| 'hover:shadow-lg hover:border-primary/30', | |
| isSelected | |
| ? 'border-primary bg-primary/5 shadow-md' | |
| : 'border-border' | |
| )} | |
| <div | |
| onClick={onToggleSelect} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" || e.key === " ") { | |
| e.preventDefault(); | |
| onToggleSelect(); | |
| } | |
| }} | |
| role="button" | |
| tabIndex={0} | |
| aria-pressed={isSelected} | |
| className={cn( | |
| 'relative rounded-xl border-2 p-5 cursor-pointer transition-all duration-200 bg-card', | |
| 'hover:shadow-lg hover:border-primary/30', | |
| isSelected | |
| ? 'border-primary bg-primary/5 shadow-md' | |
| : 'border-border' | |
| )} | |
| </div> |
🤖 Prompt for AI Agents
In `@tutor-finder/src/components/TutorCard.tsx` around lines 25 - 33, The div used
as the clickable card in the TutorCard component should be made keyboard- and
screen-reader-accessible: add role="button", tabIndex={0},
aria-pressed={isSelected}, and an onKeyDown handler that listens for Enter and
Space and calls onToggleSelect (for Space call preventDefault to stop page
scroll) so keyboard users can toggle selection; update the element where
onToggleSelect, isSelected and cn are used to include these attributes and the
handler.
| >(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => { | ||
| const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); | ||
|
|
||
| if (collapsible === "none") { | ||
| return ( | ||
| <div | ||
| className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)} | ||
| ref={ref} | ||
| {...props} | ||
| > | ||
| {children} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (isMobile) { | ||
| return ( | ||
| <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> | ||
| <SheetContent | ||
| data-sidebar="sidebar" | ||
| data-mobile="true" | ||
| className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" | ||
| style={ | ||
| { | ||
| "--sidebar-width": SIDEBAR_WIDTH_MOBILE, | ||
| } as React.CSSProperties | ||
| } | ||
| side={side} | ||
| > | ||
| <div className="flex h-full w-full flex-col">{children}</div> | ||
| </SheetContent> | ||
| </Sheet> | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
git ls-files | grep -i sidebarRepository: tinyfish-io/tinyfish-cookbook
Length of output: 390
🏁 Script executed:
head -200 tutor-finder/src/components/ui/sidebar.tsx | tail -100Repository: tinyfish-io/tinyfish-cookbook
Length of output: 3421
🏁 Script executed:
wc -l tutor-finder/src/components/ui/sidebar.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 119
🏁 Script executed:
sed -n '100,200p' tutor-finder/src/components/ui/sidebar.tsxRepository: tinyfish-io/tinyfish-cookbook
Length of output: 3437
🏁 Script executed:
# Run the search for Sidebar usages with className/id/data-/aria-/ref as suggested in the review
rg -n --type=tsx -C2 -P '<Sidebar\b[^>]*(className|id|data-|aria-|ref)=' tutor-finder/Repository: tinyfish-io/tinyfish-cookbook
Length of output: 100
🏁 Script executed:
rg -n -C2 '<Sidebar\b[^>]*(className|id|data-|aria-|ref)=' tutor-finder/Repository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
# Let's get the complete sidebar.tsx to understand the full picture
sed -n '130,180p' tutor-finder/src/components/ui/sidebar.tsx | cat -nRepository: tinyfish-io/tinyfish-cookbook
Length of output: 1912
🏁 Script executed:
# Let's look for all Sidebar component usages in tutor-finder
rg -n 'Sidebar' tutor-finder/src/ -t jsx -t js | head -30Repository: tinyfish-io/tinyfish-cookbook
Length of output: 100
🏁 Script executed:
# Check the file type and search differently
find tutor-finder/src -name "*sidebar*" -o -name "*Sidebar*"Repository: tinyfish-io/tinyfish-cookbook
Length of output: 115
🏁 Script executed:
# Let's see if there are any component files that use Sidebar
rg '<Sidebar' tutor-finder/src/ -A3 -B1 | head -50Repository: tinyfish-io/tinyfish-cookbook
Length of output: 1102
🏁 Script executed:
# Let's look for the Sheet component to understand how it handles props
rg -n 'Sheet' tutor-finder/src/components/ui/ | head -20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 2126
🏁 Script executed:
# Get the complete Sidebar component to see all branches
sed -n '130,220p' tutor-finder/src/components/ui/sidebar.tsx | cat -nRepository: tinyfish-io/tinyfish-cookbook
Length of output: 4105
Preserve Sidebar props/ref in mobile rendering.
The mobile branch currently spreads ...props onto <Sheet> and doesn't forward ref to SheetContent, so consumer className, id, data-*, and refs don't reach the rendered sidebar on mobile. This is inconsistent with the desktop and collapsible="none" branches, which properly apply these attributes. Move props and ref to SheetContent and merge style/className to maintain consistent behavior across breakpoints.
🛠️ Proposed fix
->(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
+>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, style, children, ...props }, ref) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (isMobile) {
return (
- <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
+ <Sheet open={openMobile} onOpenChange={setOpenMobile}>
<SheetContent
+ ref={ref}
data-sidebar="sidebar"
data-mobile="true"
- className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
+ className={cn("w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden", className)}
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
+ ...style,
} as React.CSSProperties
}
side={side}
+ {...props}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| >(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => { | |
| const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); | |
| if (collapsible === "none") { | |
| return ( | |
| <div | |
| className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)} | |
| ref={ref} | |
| {...props} | |
| > | |
| {children} | |
| </div> | |
| ); | |
| } | |
| if (isMobile) { | |
| return ( | |
| <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> | |
| <SheetContent | |
| data-sidebar="sidebar" | |
| data-mobile="true" | |
| className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" | |
| style={ | |
| { | |
| "--sidebar-width": SIDEBAR_WIDTH_MOBILE, | |
| } as React.CSSProperties | |
| } | |
| side={side} | |
| > | |
| <div className="flex h-full w-full flex-col">{children}</div> | |
| </SheetContent> | |
| </Sheet> | |
| ); | |
| >(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, style, children, ...props }, ref) => { | |
| const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); | |
| if (collapsible === "none") { | |
| return ( | |
| <div | |
| className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)} | |
| ref={ref} | |
| {...props} | |
| > | |
| {children} | |
| </div> | |
| ); | |
| } | |
| if (isMobile) { | |
| return ( | |
| <Sheet open={openMobile} onOpenChange={setOpenMobile}> | |
| <SheetContent | |
| ref={ref} | |
| data-sidebar="sidebar" | |
| data-mobile="true" | |
| className={cn("w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden", className)} | |
| style={ | |
| { | |
| "--sidebar-width": SIDEBAR_WIDTH_MOBILE, | |
| ...style, | |
| } as React.CSSProperties | |
| } | |
| side={side} | |
| {...props} | |
| > | |
| <div className="flex h-full w-full flex-col">{children}</div> | |
| </SheetContent> | |
| </Sheet> | |
| ); | |
| } |
🤖 Prompt for AI Agents
In `@tutor-finder/src/components/ui/sidebar.tsx` around lines 138 - 170, The
mobile rendering currently spreads ...props onto Sheet and doesn't forward ref
or merge className/style into SheetContent; update the mobile branch so
SheetContent (not Sheet) receives the forwarded ref and the component props
(merge incoming className with the existing class string and merge the style
object to include "--sidebar-width": SIDEBAR_WIDTH_MOBILE while preserving any
provided styles), and remove spreading ...props onto Sheet; modify the JSX for
SheetContent (used inside the isMobile branch) to accept ref, {...props} and the
merged className and style so consumer attributes (className, id, data-*, refs)
propagate the same way as in the collapsible="none" and desktop branches.
| import { useTheme } from "next-themes"; | ||
| import { Toaster as Sonner, toast } from "sonner"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if ThemeProvider is set up and which toast system is actually used
echo "=== Checking for ThemeProvider setup ==="
rg -n "ThemeProvider" --type=tsx --type=ts tutor-finder/src/
echo ""
echo "=== Checking toast usage patterns ==="
rg -n "from ['\"]sonner['\"]|from.*use-toast" --type=tsx --type=ts tutor-finder/src/
echo ""
echo "=== Checking app root/layout structure ==="
fd -e tsx -e ts "app\|main\|root\|layout" tutor-finder/src/ | head -20Repository: tinyfish-io/tinyfish-cookbook
Length of output: 269
🏁 Script executed:
#!/bin/bash
# Explore directory structure first
echo "=== Repository structure ==="
git ls-files | head -30
echo ""
echo "=== Checking for ThemeProvider setup ==="
rg -n "ThemeProvider"
echo ""
echo "=== Checking for sonner imports ==="
rg -n "from.*['\"]sonner['\"]"
echo ""
echo "=== Checking for useTheme imports ==="
rg -n "from.*['\"]next-themes['\"]"
echo ""
echo "=== Checking for useToast imports ==="
rg -n "useToast"Repository: tinyfish-io/tinyfish-cookbook
Length of output: 5152
🏁 Script executed:
#!/bin/bash
echo "=== tutor-finder app/main structure ==="
find tutor-finder/src -maxdepth 2 -type f -name "*.tsx" -o -name "*.ts" | grep -E "(main|app|root|layout|index)" | head -20
echo ""
echo "=== tutor-finder package.json ==="
cat tutor-finder/package.json
echo ""
echo "=== Check if ThemeProvider is used in tutor-finder ==="
rg -n "ThemeProvider" tutor-finder/
echo ""
echo "=== Check actual usage of sonner in tutor-finder ==="
rg -n "Sonner|toast" tutor-finder/src/components/ui/sonner.tsx -A 20
echo ""
echo "=== Check if sonner Toaster is rendered anywhere ==="
rg -n "<Sonner|<Toaster" tutor-finder/src/Repository: tinyfish-io/tinyfish-cookbook
Length of output: 4488
Remove unused sonner integration or consolidate toast systems.
The sonner.tsx file has two unresolved issues:
-
Missing ThemeProvider: The
useTheme()hook fromnext-themesrequires aThemeProviderwrapper at the app root, which is not present in this Vite+React setup. Without it, the hook will fail.next-themesis designed for Next.js and requires additional configuration for Vite+React. -
Duplicate toast systems: Both
<Toaster />(custom, fromhooks/use-toast.ts) and<Sonner />(fromsonner.tsx) are rendered simultaneously inApp.tsx(lines 14–15). Choose one and remove the other to avoid maintenance overhead and potential conflicts.
🤖 Prompt for AI Agents
In `@tutor-finder/src/components/ui/sonner.tsx` around lines 1 - 2, The sonner
integration should be removed to avoid the broken next-themes dependency and
duplicate toasters: delete or stop using the sonner.tsx module (remove imports
of { Toaster as Sonner, toast } and any useTheme() calls), and in App.tsx keep
only your existing custom Toaster from hooks/use-toast.ts (remove the Sonner
component and any calls to toast from sonner); if you do want a single
sonner-based system instead, replace the useTheme() usage with a
Vite/React-compatible theme provider and remove the custom Toaster, ensuring
only one toaster component (Sonner or your Toaster) is rendered.
| function useToast() { | ||
| const [state, setState] = React.useState<State>(memoryState); | ||
|
|
||
| React.useEffect(() => { | ||
| listeners.push(setState); | ||
| return () => { | ||
| const index = listeners.indexOf(setState); | ||
| if (index > -1) { | ||
| listeners.splice(index, 1); | ||
| } | ||
| }; | ||
| }, [state]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's locate and read the file
find . -path "*/tutor-finder/src/hooks/use-toast.ts" -type fRepository: tinyfish-io/tinyfish-cookbook
Length of output: 110
🏁 Script executed:
# Read the file to see the full context
cat -n tutor-finder/src/hooks/use-toast.tsRepository: tinyfish-io/tinyfish-cookbook
Length of output: 5309
Fix the useEffect dependency array to prevent listener resubscription on every state change.
The effect sets up a listener subscription and should only run once on mount and cleanup once on unmount. Including state in the dependency array causes unnecessary teardown and re-subscription on every state change, which can miss dispatches between cleanup and re-setup.
Proposed fix
- React.useEffect(() => {
- listeners.push(setState);
- return () => {
- const index = listeners.indexOf(setState);
- if (index > -1) {
- listeners.splice(index, 1);
- }
- };
- }, [state]);
+ React.useEffect(() => {
+ listeners.push(setState);
+ return () => {
+ const index = listeners.indexOf(setState);
+ if (index > -1) {
+ listeners.splice(index, 1);
+ }
+ };
+ }, []);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function useToast() { | |
| const [state, setState] = React.useState<State>(memoryState); | |
| React.useEffect(() => { | |
| listeners.push(setState); | |
| return () => { | |
| const index = listeners.indexOf(setState); | |
| if (index > -1) { | |
| listeners.splice(index, 1); | |
| } | |
| }; | |
| }, [state]); | |
| function useToast() { | |
| const [state, setState] = React.useState<State>(memoryState); | |
| React.useEffect(() => { | |
| listeners.push(setState); | |
| return () => { | |
| const index = listeners.indexOf(setState); | |
| if (index > -1) { | |
| listeners.splice(index, 1); | |
| } | |
| }; | |
| }, []); |
🤖 Prompt for AI Agents
In `@tutor-finder/src/hooks/use-toast.ts` around lines 166 - 177, The effect in
useToast currently depends on state causing listeners to be removed and re-added
on every state change; update the React.useEffect dependency array for the
listener registration/cleanup to an empty array so the effect runs only once on
mount and once on unmount, keeping the listeners array stable and ensuring
setState subscriptions (listeners.push/setState and the cleanup that splices
listeners) are not resubscribed on each state update.
| const resetSearch = () => { | ||
| setState({ | ||
| exam: null, | ||
| location: '', | ||
| isSearching: false, | ||
| isDiscovering: false, | ||
| agents: [], | ||
| tutors: [], | ||
| selectedTutorIds: new Set(), | ||
| }); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resetSearch should abort in-flight requests.
Currently resetSearch only clears state but doesn't cancel active searches. If a search is in progress, it will continue running and eventually update state, potentially overwriting the reset state.
🔧 Proposed fix
const resetSearch = () => {
+ abortControllerRef.current?.abort();
setState({
exam: null,
location: '',
isSearching: false,
isDiscovering: false,
agents: [],
tutors: [],
selectedTutorIds: new Set(),
});
};🤖 Prompt for AI Agents
In `@tutor-finder/src/hooks/useTutorSearch.ts` around lines 32 - 42, resetSearch
currently only clears state (setState) but doesn't cancel ongoing network/search
operations; modify the hook to track the current AbortController (or
cancellation token) in a ref (e.g., currentAbortRef) used by your search routine
(the function that initiates searches) and call currentAbortRef.current?.abort()
inside resetSearch before clearing state, then clear/reset the ref after
aborting so no in-flight promise can later call setState; update the
search-start path to create and assign a new AbortController to that ref for
each new search and wire fetch/requests to use its signal so they can be
cancelled.
| const startSearch = useCallback(async (exam: ExamType, location: string) => { | ||
| setState((prev) => ({ | ||
| ...prev, | ||
| location, | ||
| isSearching: true, | ||
| isDiscovering: true, | ||
| agents: [], | ||
| tutors: [], | ||
| selectedTutorIds: new Set(), | ||
| })); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing AbortController for request cancellation.
The startSearch function initiates multiple fetch requests but provides no mechanism to abort them if the component unmounts or the user triggers a new search. This can cause memory leaks and "setState on unmounted component" warnings.
🔧 Proposed fix - add AbortController
+import { useState, useCallback, useRef, useEffect } from 'react';
+
export function useTutorSearch() {
+ const abortControllerRef = useRef<AbortController | null>(null);
+
const [state, setState] = useState<SearchState>({
// ...
});
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ abortControllerRef.current?.abort();
+ };
+ }, []);
const startSearch = useCallback(async (exam: ExamType, location: string) => {
+ // Abort any in-flight requests
+ abortControllerRef.current?.abort();
+ abortControllerRef.current = new AbortController();
+ const signal = abortControllerRef.current.signal;
+
setState((prev) => ({
// ...
}));Then pass signal to fetch calls:
const response = await fetch(
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/search-tutors-mino`,
{
method: 'POST',
+ signal,
headers: {🤖 Prompt for AI Agents
In `@tutor-finder/src/hooks/useTutorSearch.ts` around lines 44 - 53, startSearch
currently kicks off fetch requests without cancellation; create an
AbortController at the start of startSearch (and keep the current controller in
a ref like searchAbortRef), call searchAbortRef.current?.abort() before creating
a new controller to cancel prior requests, pass controller.signal into all fetch
calls started by startSearch, catch and ignore AbortError in the promise
handlers to avoid setState on unmounted components, and add a cleanup useEffect
that aborts the controller on unmount; refer to startSearch, setState, and any
internal fetch calls invoked by startSearch to make these changes.
| for (const line of lines) { | ||
| if (line.trim()) { | ||
| // Forward the SSE event to the client | ||
| await writer.write(encoder.encode(line + '\n')); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's read the file to understand the context around lines 105-110
cat -n tutor-finder/supabase/functions/search-tutors-mino/index.ts | head -120 | tail -40Repository: tinyfish-io/tinyfish-cookbook
Length of output: 1562
🏁 Script executed:
# Also check file size to understand the full scope
wc -l tutor-finder/supabase/functions/search-tutors-mino/index.tsRepository: tinyfish-io/tinyfish-cookbook
Length of output: 136
🏁 Script executed:
# Run the verification script to find SSE client implementations
rg -n "EventSource|text/event-stream" --type ts --type tsx -g '!node_modules'Repository: tinyfish-io/tinyfish-cookbook
Length of output: 100
🏁 Script executed:
# Search for SSE and EventSource with correct flags
rg -n "EventSource|text/event-stream" --type ts -g '!node_modules'Repository: tinyfish-io/tinyfish-cookbook
Length of output: 1443
🏁 Script executed:
# Search for Mino API references and documentation
rg -n "mino|Mino" --type ts -A 3 -B 3 | head -100Repository: tinyfish-io/tinyfish-cookbook
Length of output: 8571
🏁 Script executed:
# Look at the full file to understand the Mino API response handling
cat -n tutor-finder/supabase/functions/search-tutors-mino/index.tsRepository: tinyfish-io/tinyfish-cookbook
Length of output: 6070
🏁 Script executed:
# Search for EventSource usage in client code
rg -n "EventSource|readyState|addEventListener.*message" tutor-finder/src -A 3Repository: tinyfish-io/tinyfish-cookbook
Length of output: 55
🏁 Script executed:
# Also check for any other SSE stream handling patterns
rg -n "\.split\('\\\\n'\)|event-stream" tutor-finder -B 2 -A 2Repository: tinyfish-io/tinyfish-cookbook
Length of output: 1398
🏁 Script executed:
# Check the useTutorSearch.ts file to see how it consumes the SSE stream
cat -n tutor-finder/src/hooks/useTutorSearch.ts | sed -n '100,160p'Repository: tinyfish-io/tinyfish-cookbook
Length of output: 2548
🏁 Script executed:
# Also look for fetch response handling with SSE
cat -n tutor-finder/src/hooks/useTutorSearch.ts | sed -n '100,180p'Repository: tinyfish-io/tinyfish-cookbook
Length of output: 3664
SSE events must use double newlines (\n\n) as delimiters per protocol specification.
The current logic splits incoming data by \n (line 102) and filters empty lines (line 106), which strips the double-newline event boundaries from the Mino API stream. While the client-side parser works around this by checking for data: prefix, this violates SSE protocol and will break with standard SSE clients (e.g., EventSource API).
To fix: preserve event boundaries when forwarding:
- Split by
\n\ninstead of\nto isolate complete events - Rewrite each event with proper
\n\ndelimiters
🤖 Prompt for AI Agents
In `@tutor-finder/supabase/functions/search-tutors-mino/index.ts` around lines 105
- 110, The SSE forwarding loop currently splits the incoming stream by single
newlines and writes each non-empty line via writer.write(encoder.encode(line +
'\n')), which strips required SSE event boundaries; change the parsing to split
incoming chunks on '\n\n' to extract full SSE events (instead of splitting on
'\n'), and when forwarding each event use the proper '\n\n' delimiter (e.g.,
rewrite each event string and call writer.write(encoder.encode(event +
'\n\n'))). Update the code that produces "lines" to produce "events" split by
'\n\n' and keep references to writer and encoder (and the loop that iterates
over lines/events) so event boundaries conform to the SSE protocol.
| try { | ||
| await writer.write(encoder.encode(`data: {"type":"ERROR","message":"${error instanceof Error ? error.message : 'Unknown error'}"}\n\n`)); | ||
| await writer.close(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Escape error message to prevent malformed JSON in SSE events.
If error.message contains quotes or special characters, the JSON in the SSE event will be malformed, potentially breaking client-side parsing.
🐛 Proposed fix
try {
- await writer.write(encoder.encode(`data: {"type":"ERROR","message":"${error instanceof Error ? error.message : 'Unknown error'}"}\n\n`));
+ const errorMsg = error instanceof Error ? error.message : 'Unknown error';
+ await writer.write(encoder.encode(`data: ${JSON.stringify({ type: "ERROR", message: errorMsg })}\n\n`));
await writer.close();📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| try { | |
| await writer.write(encoder.encode(`data: {"type":"ERROR","message":"${error instanceof Error ? error.message : 'Unknown error'}"}\n\n`)); | |
| await writer.close(); | |
| try { | |
| const errorMsg = error instanceof Error ? error.message : 'Unknown error'; | |
| await writer.write(encoder.encode(`data: ${JSON.stringify({ type: "ERROR", message: errorMsg })}\n\n`)); | |
| await writer.close(); |
🤖 Prompt for AI Agents
In `@tutor-finder/supabase/functions/search-tutors-mino/index.ts` around lines 122
- 124, The SSE error payload is built by interpolating error.message directly,
which can break JSON if it contains quotes/special chars; instead serialize the
whole object with JSON.stringify before sending so the message is properly
escaped—replace the current writer.write(encoder.encode(`data:
{"type":"ERROR","message":"${error instanceof Error ? error.message : 'Unknown
error'}"}\n\n`)) usage with constructing an object (e.g., { type: "ERROR",
message: error instanceof Error ? error.message : "Unknown error" }),
JSON.stringify(...) and then write `data: ${json}\n\n` via writer.write and
encoder.encode to ensure valid SSE JSON.
No description provided.